diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-11-20 01:11:55 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-11-20 01:11:55 +0300 |
commit | 5a8431feceba47fd8e1804d9aa1b1730606b71d5 (patch) | |
tree | e5df8e0ceee60f4af8093f5c4c2f934b8abced05 /app | |
parent | 4d477238500c347c6553d335d920bedfc5a46869 (diff) |
Add latest changes from gitlab-org/gitlab@12-5-stable-ee
Diffstat (limited to 'app')
824 files changed, 13355 insertions, 5290 deletions
diff --git a/app/assets/images/cluster_app_logos/crossplane.png b/app/assets/images/cluster_app_logos/crossplane.png Binary files differnew file mode 100644 index 00000000000..32d8175108c --- /dev/null +++ b/app/assets/images/cluster_app_logos/crossplane.png diff --git a/app/assets/images/cluster_app_logos/elastic_stack.png b/app/assets/images/cluster_app_logos/elastic_stack.png Binary files differnew file mode 100644 index 00000000000..69fbc6aacd0 --- /dev/null +++ b/app/assets/images/cluster_app_logos/elastic_stack.png diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 908dc730aa4..aee9990bc0b 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -2,6 +2,8 @@ import $ from 'jquery'; import _ from 'underscore'; import axios from './lib/utils/axios_utils'; import { joinPaths } from './lib/utils/url_utility'; +import flash from '~/flash'; +import { __ } from '~/locale'; const Api = { groupsPath: '/api/:version/groups.json', @@ -29,6 +31,7 @@ const Api = { usersPath: '/api/:version/users.json', userPath: '/api/:version/users/:id', userStatusPath: '/api/:version/users/:id/status', + userProjectsPath: '/api/:version/users/:id/projects', userPostStatusPath: '/api/:version/user/status', commitPath: '/api/:version/projects/:id/repository/commits', applySuggestionPath: '/api/:version/suggestions/:id/apply', @@ -110,10 +113,9 @@ const Api = { .get(url, { params: Object.assign(defaults, options), }) - .then(({ data }) => { + .then(({ data, headers }) => { callback(data); - - return data; + return { data, headers }; }); }, @@ -239,7 +241,8 @@ const Api = { .get(url, { params: Object.assign({}, defaults, options), }) - .then(({ data }) => callback(data)); + .then(({ data }) => callback(data)) + .catch(() => flash(__('Something went wrong while fetching projects'))); }, commitMultiple(id, data) { @@ -348,6 +351,20 @@ const Api = { }); }, + userProjects(userId, query, options, callback) { + const url = Api.buildUrl(Api.userProjectsPath).replace(':id', userId); + const defaults = { + search: query, + per_page: 20, + }; + return axios + .get(url, { + params: Object.assign({}, defaults, options), + }) + .then(({ data }) => callback(data)) + .catch(() => flash(__('Something went wrong while fetching projects'))); + }, + branches(id, query = '', options = {}) { const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id)); diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index a07942d87cb..ca91400eac7 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var */ +/* eslint-disable func-names */ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; @@ -12,11 +12,8 @@ import { __ } from '~/locale'; // more than `x` users are referenced. // -var lastTextareaPreviewed; -var lastTextareaHeight = null; -var markdownPreview; -var previewButtonSelector; -var writeButtonSelector; +let lastTextareaHeight; +let lastTextareaPreviewed; function MarkdownPreview() {} @@ -27,14 +24,13 @@ MarkdownPreview.prototype.emptyMessage = __('Nothing to preview.'); MarkdownPreview.prototype.ajaxCache = {}; MarkdownPreview.prototype.showPreview = function($form) { - var mdText; - var preview = $form.find('.js-md-preview'); - var url = preview.data('url'); + const preview = $form.find('.js-md-preview'); + const url = preview.data('url'); if (preview.hasClass('md-preview-loading')) { return; } - mdText = $form.find('textarea.markdown-area').val(); + const mdText = $form.find('textarea.markdown-area').val(); if (mdText === undefined) { return; @@ -46,7 +42,7 @@ MarkdownPreview.prototype.showPreview = function($form) { } else { preview.addClass('md-preview-loading').text(__('Loading...')); this.fetchMarkdownPreview(mdText, url, response => { - var body; + let body; if (response.body.length > 0) { ({ body } = response); } else { @@ -91,8 +87,7 @@ MarkdownPreview.prototype.hideReferencedUsers = function($form) { }; MarkdownPreview.prototype.renderReferencedUsers = function(users, $form) { - var referencedUsers; - referencedUsers = $form.find('.referenced-users'); + const referencedUsers = $form.find('.referenced-users'); if (referencedUsers.length) { if (users.length >= this.referenceThreshold) { referencedUsers.show(); @@ -108,8 +103,7 @@ MarkdownPreview.prototype.hideReferencedCommands = function($form) { }; MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) { - var referencedCommands; - referencedCommands = $form.find('.referenced-commands'); + const referencedCommands = $form.find('.referenced-commands'); if (commands.length > 0) { referencedCommands.html(commands); referencedCommands.show(); @@ -119,15 +113,15 @@ MarkdownPreview.prototype.renderReferencedCommands = function(commands, $form) { } }; -markdownPreview = new MarkdownPreview(); +const markdownPreview = new MarkdownPreview(); -previewButtonSelector = '.js-md-preview-button'; -writeButtonSelector = '.js-md-write-button'; +const previewButtonSelector = '.js-md-preview-button'; +const writeButtonSelector = '.js-md-write-button'; lastTextareaPreviewed = null; const markdownToolbar = $('.md-header-toolbar'); $.fn.setupMarkdownPreview = function() { - var $form = $(this); + const $form = $(this); $form.find('textarea.markdown-area').on('input', () => { markdownPreview.hideReferencedUsers($form); }); @@ -188,7 +182,7 @@ $(document).on('markdown-preview:hide', (e, $form) => { }); $(document).on('markdown-preview:toggle', (e, keyboardEvent) => { - var $target; + let $target; $target = $(keyboardEvent.target); if ($target.is('textarea.markdown-area')) { $(document).triggerHandler('markdown-preview:show', [$target.closest('form')]); @@ -201,16 +195,14 @@ $(document).on('markdown-preview:toggle', (e, keyboardEvent) => { }); $(document).on('click', previewButtonSelector, function(e) { - var $form; e.preventDefault(); - $form = $(this).closest('form'); + const $form = $(this).closest('form'); $(document).triggerHandler('markdown-preview:show', [$form]); }); $(document).on('click', writeButtonSelector, function(e) { - var $form; e.preventDefault(); - $form = $(this).closest('form'); + const $form = $(this).closest('form'); $(document).triggerHandler('markdown-preview:hide', [$form]); }); diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index b371f6be268..aedd8004ea5 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -118,8 +118,6 @@ export default class FileTemplateMediator { } }); - this.setFilename(item.name); - if (this.editor.getValue() !== '') { this.setTypeSelectorToggleText(item.name); } @@ -133,14 +131,16 @@ export default class FileTemplateMediator { selectTemplateFile(selector, query, data) { const self = this; + const { name } = selector.config; selector.renderLoading(); this.fetchFileTemplate(selector.config.type, query, data) .then(file => { this.setEditorContent(file); + this.setFilename(name); selector.renderLoaded(); - this.typeSelector.setToggleText(selector.config.name); + this.typeSelector.setToggleText(name); toast(__(`${query} template applied`), { action: { text: __('Undo'), diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 34560560756..c0df8b72095 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -133,7 +133,7 @@ export default { if (this.board.name.length === 0) return; this.isLoading = true; if (this.isDeleteForm) { - gl.boardService + boardsStore .deleteBoard(this.currentBoard) .then(() => { visitUrl(boardsStore.rootPath); @@ -143,7 +143,7 @@ export default { this.isLoading = false; }); } else { - gl.boardService + boardsStore .createBoard(this.board) .then(resp => resp.data) .then(data => { diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 1273fcc6a91..b8439bc8741 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -84,7 +84,8 @@ export default { this.$nextTick(() => { if ( this.scrollHeight() <= this.listHeight() && - this.list.issuesSize > this.list.issues.length + this.list.issuesSize > this.list.issues.length && + this.list.isExpanded ) { this.list.page += 1; this.list.getIssues(false).catch(() => { diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index 334c162954e..32491dfbcb6 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -168,7 +168,7 @@ export default { } const recentBoardsPromise = new Promise((resolve, reject) => - gl.boardService + boardsStore .recentBoards() .then(resolve) .catch(err => { @@ -184,7 +184,7 @@ export default { }), ); - Promise.all([gl.boardService.allBoards(), recentBoardsPromise]) + Promise.all([boardsStore.allBoards(), recentBoardsPromise]) .then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data]) .then(([allBoardsJson, recentBoardsJson]) => { this.loading = false; diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index 40d75d53f75..d37e49bab46 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -1,5 +1,6 @@ <script> import _ from 'underscore'; +import { mapState } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; @@ -63,6 +64,7 @@ export default { }; }, computed: { + ...mapState(['isShowingLabels']), numberOverLimit() { return this.issue.assignees.length - this.limitBeforeCounter; }, @@ -92,7 +94,7 @@ export default { return false; }, showLabelFooter() { - return this.issue.labels.find(l => this.showLabel(l)) !== undefined; + return this.isShowingLabels && this.issue.labels.find(this.showLabel); }, issueReferencePath() { const { referencePath, groupId } = this.issue; diff --git a/app/assets/javascripts/boards/components/modal/index.vue b/app/assets/javascripts/boards/components/modal/index.vue index defa1f75ba2..618c2ada1f8 100644 --- a/app/assets/javascripts/boards/components/modal/index.vue +++ b/app/assets/javascripts/boards/components/modal/index.vue @@ -1,6 +1,7 @@ <script> /* global ListIssue */ import { urlParamsToObject } from '~/lib/utils/common_utils'; +import boardsStore from '~/boards/stores/boards_store'; import ModalHeader from './header.vue'; import ModalList from './list.vue'; import ModalFooter from './footer.vue'; @@ -109,7 +110,7 @@ export default { loadIssues(clearIssues = false) { if (!this.showAddIssuesModal) return false; - return gl.boardService + return boardsStore .getBacklog({ ...urlParamsToObject(this.filter.path), page: this.page, diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index befca70eeae..e76e2341dfd 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -13,6 +13,7 @@ import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; import '~/boards/models/milestone'; import '~/boards/models/project'; +import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; import ModalStore from '~/boards/stores/modal_store'; import BoardService from 'ee_else_ce/boards/services/board_service'; @@ -29,6 +30,7 @@ import { } from '~/lib/utils/common_utils'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; +import toggleLabels from 'ee_else_ce/boards/toggle_labels'; import { setPromotionState, setWeigthFetchingState, @@ -67,6 +69,7 @@ export default () => { BoardSidebar, BoardAddIssuesModal, }, + store, data: { state: boardsStore.state, loading: true, @@ -314,5 +317,6 @@ export default () => { } toggleFocusMode(ModalStore, boardsStore, $boardApp); + toggleLabels(); mountMultipleBoardsSwitcher(); }; diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 1e213c324eb..bb8c8e68297 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -50,8 +50,8 @@ class List { this.page = 1; this.loading = true; this.loadingMore = false; - this.issues = []; - this.issuesSize = 0; + this.issues = obj.issues || []; + this.issuesSize = obj.issuesSize ? obj.issuesSize : 0; this.defaultAvatar = defaultAvatar; if (obj.label) { diff --git a/app/assets/javascripts/boards/stores/getters.js b/app/assets/javascripts/boards/stores/getters.js new file mode 100644 index 00000000000..4de1576099d --- /dev/null +++ b/app/assets/javascripts/boards/stores/getters.js @@ -0,0 +1,3 @@ +export default { + getLabelToggleState: state => (state.isShowingLabels ? 'on' : 'off'), +}; diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js index f70395a3771..471b952a212 100644 --- a/app/assets/javascripts/boards/stores/index.js +++ b/app/assets/javascripts/boards/stores/index.js @@ -1,14 +1,18 @@ import Vue from 'vue'; import Vuex from 'vuex'; import state from 'ee_else_ce/boards/stores/state'; +import getters from 'ee_else_ce/boards/stores/getters'; import actions from 'ee_else_ce/boards/stores/actions'; import mutations from 'ee_else_ce/boards/stores/mutations'; Vue.use(Vuex); -export default () => +export const createStore = () => new Vuex.Store({ state, + getters, actions, mutations, }); + +export default createStore(); diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index dd16abb01a5..24f44dc5629 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,3 +1,3 @@ export default () => ({ - // ... + isShowingLabels: true, }); diff --git a/app/assets/javascripts/boards/toggle_labels.js b/app/assets/javascripts/boards/toggle_labels.js new file mode 100644 index 00000000000..2d1ec238274 --- /dev/null +++ b/app/assets/javascripts/boards/toggle_labels.js @@ -0,0 +1 @@ +export default () => {}; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 7ea8901ecbb..75909dd9d20 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -8,11 +8,12 @@ import Flash from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX } from './constants'; +import { APPLICATION_STATUS, INGRESS, INGRESS_DOMAIN_SUFFIX, CROSSPLANE } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; import setupToggleButtons from '../toggle_buttons'; +import initProjectSelectDropdown from '~/project_select'; const Environments = () => import('ee_component/clusters/components/environments.vue'); @@ -37,6 +38,8 @@ export default class Clusters { installJupyterPath, installKnativePath, updateKnativePath, + installElasticStackPath, + installCrossplanePath, installPrometheusPath, managePrometheusPath, clusterEnvironmentsPath, @@ -81,11 +84,13 @@ export default class Clusters { installHelmEndpoint: installHelmPath, installIngressEndpoint: installIngressPath, installCertManagerEndpoint: installCertManagerPath, + installCrossplaneEndpoint: installCrossplanePath, installRunnerEndpoint: installRunnerPath, installPrometheusEndpoint: installPrometheusPath, installJupyterEndpoint: installJupyterPath, installKnativeEndpoint: installKnativePath, updateKnativeEndpoint: updateKnativePath, + installElasticStackEndpoint: installElasticStackPath, clusterEnvironmentsEndpoint: clusterEnvironmentsPath, }); @@ -108,8 +113,10 @@ export default class Clusters { this.ingressDomainHelpText && this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet'); + initProjectSelectDropdown(); Clusters.initDismissableCallout(); initSettingsPanels(); + const toggleButtonsContainer = document.querySelector('.js-cluster-enable-toggle-area'); if (toggleButtonsContainer) { setupToggleButtons(toggleButtonsContainer); @@ -222,6 +229,7 @@ export default class Clusters { eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data)); eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data)); eventHub.$on('uninstallApplication', data => this.uninstallApplication(data)); + eventHub.$on('setCrossplaneProviderStack', data => this.setCrossplaneProviderStack(data)); // Add event listener to all the banner close buttons this.addBannerCloseHandler(this.unreachableContainer, 'unreachable'); this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure'); @@ -233,6 +241,7 @@ export default class Clusters { eventHub.$off('updateApplication', this.updateApplication); eventHub.$off('saveKnativeDomain'); eventHub.$off('setKnativeHostname'); + eventHub.$off('setCrossplaneProviderStack'); eventHub.$off('uninstallApplication'); } @@ -399,18 +408,33 @@ export default class Clusters { } installApplication({ id: appId, params }) { - this.store.updateAppProperty(appId, 'requestReason', null); - this.store.updateAppProperty(appId, 'statusReason', null); + return Clusters.validateInstallation(appId, params) + .then(() => { + this.store.updateAppProperty(appId, 'requestReason', null); + this.store.updateAppProperty(appId, 'statusReason', null); + this.store.installApplication(appId); + + // eslint-disable-next-line promise/no-nesting + this.service.installApplication(appId, params).catch(() => { + this.store.notifyInstallFailure(appId); + this.store.updateAppProperty( + appId, + 'requestReason', + s__('ClusterIntegration|Request to begin installing failed'), + ); + }); + }) + .catch(error => this.store.updateAppProperty(appId, 'validationError', error)); + } - this.store.installApplication(appId); + static validateInstallation(appId, params) { + return new Promise((resolve, reject) => { + if (appId === CROSSPLANE && !params.stack) { + reject(s__('ClusterIntegration|Select a stack to install Crossplane.')); + return; + } - return this.service.installApplication(appId, params).catch(() => { - this.store.notifyInstallFailure(appId); - this.store.updateAppProperty( - appId, - 'requestReason', - s__('ClusterIntegration|Request to begin installing failed'), - ); + resolve(); }); } @@ -458,6 +482,12 @@ export default class Clusters { this.store.updateAppProperty(appId, 'hostname', data.hostname); } + setCrossplaneProviderStack(data) { + const appId = data.id; + this.store.updateAppProperty(appId, 'stack', data.stack.code); + this.store.updateAppProperty(appId, 'validationError', null); + } + destroy() { this.destroyed = true; diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index b95f97077f6..a951a6bfeea 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -9,9 +9,11 @@ import jeagerLogo from 'images/cluster_app_logos/jeager.png'; import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; +import crossplaneLogo from 'images/cluster_app_logos/crossplane.png'; 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 { s__, sprintf } from '../../locale'; import applicationRow from './application_row.vue'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; @@ -19,6 +21,7 @@ import KnativeDomainEditor from './knative_domain_editor.vue'; import { CLUSTER_TYPE, PROVIDER_TYPE, APPLICATION_STATUS, INGRESS } from '../constants'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/clusters/event_hub'; +import CrossplaneProviderStack from './crossplane_provider_stack.vue'; export default { components: { @@ -27,6 +30,7 @@ export default { LoadingButton, GlLoadingIcon, KnativeDomainEditor, + CrossplaneProviderStack, }, props: { type: { @@ -88,9 +92,11 @@ export default { jupyterhubLogo, kubernetesLogo, certManagerLogo, + crossplaneLogo, knativeLogo, meltanoLogo, prometheusLogo, + elasticStackLogo, }), computed: { isProjectCluster() { @@ -114,6 +120,15 @@ export default { certManagerInstalled() { return this.applications.cert_manager.status === APPLICATION_STATUS.INSTALLED; }, + crossplaneInstalled() { + return this.applications.crossplane.status === APPLICATION_STATUS.INSTALLED; + }, + enableClusterApplicationCrossplane() { + return gon.features && gon.features.enableClusterApplicationCrossplane; + }, + enableClusterApplicationElasticStack() { + return gon.features && gon.features.enableClusterApplicationElasticStack; + }, ingressDescription() { return sprintf( _.escape( @@ -146,6 +161,24 @@ export default { false, ); }, + crossplaneDescription() { + return sprintf( + _.escape( + s__( + `ClusterIntegration|Crossplane enables declarative provisioning of managed services from your cloud of choice using %{kubectl} or %{gitlabIntegrationLink}. +Crossplane runs inside your Kubernetes cluster and supports secure connectivity and secrets management between app containers and the cloud services they depend on.`, + ), + ), + { + gitlabIntegrationLink: `<a href="https://docs.gitlab.com/ce/user/project/integrations/crossplane.html" + target="_blank" rel="noopener noreferrer"> + ${_.escape(s__('ClusterIntegration|Gitlab Integration'))}</a>`, + kubectl: `<code>kubectl</code>`, + }, + false, + ); + }, + prometheusDescription() { return sprintf( _.escape( @@ -168,9 +201,18 @@ export default { jupyterHostname() { return this.applications.jupyter.hostname; }, + elasticStackInstalled() { + return this.applications.elastic_stack.status === APPLICATION_STATUS.INSTALLED; + }, + elasticStackKibanaHostname() { + return this.applications.elastic_stack.kibana_hostname; + }, knative() { return this.applications.knative; }, + crossplane() { + return this.applications.crossplane; + }, cloudRun() { return this.providerType === PROVIDER_TYPE.GCP && this.preInstalledKnative; }, @@ -207,6 +249,12 @@ export default { hostname, }); }, + setCrossplaneProviderStack(stack) { + eventHub.$emit('setCrossplaneProviderStack', { + id: 'crossplane', + stack, + }); + }, }, }; </script> @@ -217,7 +265,7 @@ export default { <p class="append-bottom-0"> {{ s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster. - Helm Tiller is required to install any of the following applications.`) + Helm Tiller is required to install any of the following applications.`) }} <a :href="helpPath">{{ __('More information') }}</a> </p> @@ -242,9 +290,9 @@ export default { <div slot="description"> {{ s__(`ClusterIntegration|Helm streamlines installing - and managing Kubernetes applications. - Tiller runs inside of your Kubernetes Cluster, - and manages releases of your charts.`) + and managing Kubernetes applications. + Tiller runs inside of your Kubernetes Cluster, + and manages releases of your charts.`) }} </div> </application-row> @@ -252,7 +300,7 @@ export default { <div class="svg-container" v-html="helmInstallIllustration"></div> {{ s__(`ClusterIntegration|You must first install Helm Tiller before - installing the applications below`) + installing the applications below`) }} </div> <application-row @@ -275,8 +323,8 @@ export default { <p> {{ s__(`ClusterIntegration|Ingress gives you a way to route - requests to services based on the request host or path, - centralizing a number of services into a single entrypoint.`) + requests to services based on the request host or path, + centralizing a number of services into a single entrypoint.`) }} </p> @@ -308,8 +356,8 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Point a wildcard DNS to this - generated endpoint in order to access - your application after it has been deployed.`) + generated endpoint in order to access + your application after it has been deployed.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -320,8 +368,8 @@ export default { <p v-if="!ingressExternalEndpoint" class="settings-message js-no-endpoint-message"> {{ s__(`ClusterIntegration|The endpoint is in - the process of being assigned. Please check your Kubernetes - cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) + the process of being assigned. Please check your Kubernetes + cluster or Quotas on Google Kubernetes Engine if it takes a long time.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -368,7 +416,7 @@ export default { <p class="form-text text-muted"> {{ s__(`ClusterIntegration|Issuers represent a certificate authority. - You must provide an email address for your Issuer. `) + You must provide an email address for your Issuer. `) }} <a href="http://docs.cert-manager.io/en/latest/reference/issuers.html?highlight=email" @@ -424,13 +472,41 @@ export default { <div slot="description"> {{ s__(`ClusterIntegration|GitLab Runner connects to the - repository and executes CI/CD jobs, - pushing results back and deploying - applications to production.`) + repository and executes CI/CD jobs, + pushing results back and deploying + applications to production.`) }} </div> </application-row> <application-row + v-if="enableClusterApplicationCrossplane" + id="crossplane" + :logo-url="crossplaneLogo" + :title="applications.crossplane.title" + :status="applications.crossplane.status" + :status-reason="applications.crossplane.statusReason" + :request-status="applications.crossplane.requestStatus" + :request-reason="applications.crossplane.requestReason" + :installed="applications.crossplane.installed" + :install-failed="applications.crossplane.installFailed" + :uninstallable="applications.crossplane.uninstallable" + :uninstall-successful="applications.crossplane.uninstallSuccessful" + :uninstall-failed="applications.crossplane.uninstallFailed" + :install-application-request-params="{ stack: applications.crossplane.stack }" + :disabled="!helmInstalled" + title-link="https://crossplane.io" + > + <template> + <div slot="description"> + <p v-html="crossplaneDescription"></p> + <div class="form-group"> + <CrossplaneProviderStack :crossplane="crossplane" @set="setCrossplaneProviderStack" /> + </div> + </div> + </template> + </application-row> + + <application-row id="jupyter" :logo-url="jupyterhubLogo" :title="applications.jupyter.title" @@ -451,10 +527,10 @@ export default { <p> {{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns, - manages, and proxies multiple instances of the single-user - Jupyter notebook server. JupyterHub can be used to serve - notebooks to a class of students, a corporate data science group, - or a scientific research group.`) + manages, and proxies multiple instances of the single-user + Jupyter notebook server. JupyterHub can be used to serve + notebooks to a class of students, a corporate data science group, + or a scientific research group.`) }} </p> @@ -481,7 +557,7 @@ export default { <p v-if="ingressInstalled" class="form-text text-muted"> {{ s__(`ClusterIntegration|Replace this with your own hostname if you want. - If you do so, point hostname to Ingress IP Address from above.`) + If you do so, point hostname to Ingress IP Address from above.`) }} <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> {{ __('More information') }} @@ -527,9 +603,9 @@ export default { <p> {{ s__(`ClusterIntegration|Knative extends Kubernetes to provide - a set of middleware components that are essential to build modern, - source-centric, and container-based applications that can run - anywhere: on premises, in the cloud, or even in a third-party data center.`) + a set of middleware components that are essential to build modern, + source-centric, and container-based applications that can run + anywhere: on premises, in the cloud, or even in a third-party data center.`) }} </p> @@ -542,6 +618,75 @@ export default { /> </div> </application-row> + <application-row + v-if="enableClusterApplicationElasticStack" + id="elastic_stack" + :logo-url="elasticStackLogo" + :title="applications.elastic_stack.title" + :status="applications.elastic_stack.status" + :status-reason="applications.elastic_stack.statusReason" + :request-status="applications.elastic_stack.requestStatus" + :request-reason="applications.elastic_stack.requestReason" + :version="applications.elastic_stack.version" + :chart-repo="applications.elastic_stack.chartRepo" + :update-available="applications.elastic_stack.updateAvailable" + :installed="applications.elastic_stack.installed" + :install-failed="applications.elastic_stack.installFailed" + :update-successful="applications.elastic_stack.updateSuccessful" + :update-failed="applications.elastic_stack.updateFailed" + :uninstallable="applications.elastic_stack.uninstallable" + :uninstall-successful="applications.elastic_stack.uninstallSuccessful" + :uninstall-failed="applications.elastic_stack.uninstallFailed" + :disabled="!helmInstalled" + :install-application-request-params="{ + kibana_hostname: applications.elastic_stack.kibana_hostname, + }" + title-link="https://github.com/helm/charts/tree/master/stable/elastic-stack" + > + <div slot="description"> + <p> + {{ + s__( + `ClusterIntegration|The elastic stack collects logs from all pods in your cluster`, + ) + }} + </p> + + <template v-if="ingressExternalEndpoint"> + <div class="form-group"> + <label for="elastic-stack-kibana-hostname">{{ + s__('ClusterIntegration|Kibana Hostname') + }}</label> + + <div class="input-group"> + <input + v-model="applications.elastic_stack.kibana_hostname" + :readonly="elasticStackInstalled" + type="text" + class="form-control js-hostname" + /> + <span class="input-group-btn"> + <clipboard-button + :text="elasticStackKibanaHostname" + :title="s__('ClusterIntegration|Copy Kibana Hostname')" + class="js-clipboard-btn" + /> + </span> + </div> + + <p v-if="ingressInstalled" class="form-text text-muted"> + {{ + s__(`ClusterIntegration|Replace this with your own hostname if you want. + If you do so, point hostname to Ingress IP Address from above.`) + }} + <a :href="ingressDnsHelpPath" target="_blank" rel="noopener noreferrer"> + {{ __('More information') }} + </a> + </p> + </div> + </template> + </div> + </application-row> </div> </section> </template> diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue new file mode 100644 index 00000000000..966918ae636 --- /dev/null +++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue @@ -0,0 +1,93 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { s__ } from '../../locale'; + +export default { + name: 'CrossplaneProviderStack', + components: { + GlDropdown, + GlDropdownItem, + Icon, + }, + props: { + stacks: { + type: Array, + required: false, + default: () => [ + { + name: s__('Google Cloud Platform'), + code: 'gcp', + }, + { + name: s__('Amazon Web Services'), + code: 'aws', + }, + { + name: s__('Microsoft Azure'), + code: 'azure', + }, + { + name: s__('Rook'), + code: 'rook', + }, + ], + }, + crossplane: { + type: Object, + required: true, + }, + }, + computed: { + dropdownText() { + const result = this.stacks.reduce((map, obj) => { + // eslint-disable-next-line no-param-reassign + map[obj.code] = obj.name; + return map; + }, {}); + const { stack } = this.crossplane; + if (stack !== '') { + return result[stack]; + } + return s__('Select Stack'); + }, + validationError() { + return this.crossplane.validationError; + }, + }, + methods: { + selectStack(stack) { + this.$emit('set', stack); + }, + }, +}; +</script> + +<template> + <div> + <label> + {{ s__('ClusterIntegration|Enabled stack') }} + </label> + <gl-dropdown + :disabled="crossplane.installed" + :text="dropdownText" + toggle-class="dropdown-menu-toggle gl-field-error-outline" + class="w-100" + :class="{ 'gl-show-field-errors': validationError }" + > + <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)"> + <span class="ml-1">{{ stack.name }}</span> + </gl-dropdown-item> + </gl-dropdown> + <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> + <p class="form-text text-muted"> + {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }} + <a + href="https://crossplane.io/docs/master/stacks-guide.html" + target="_blank" + rel="noopener noreferrer" + >{{ __('Crossplane') }}</a + > + </p> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue index f1925c243f2..125bcaacc1c 100644 --- a/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue +++ b/app/assets/javascripts/clusters/components/uninstall_application_confirmation_modal.vue @@ -2,7 +2,16 @@ import { GlModal } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import trackUninstallButtonClickMixin from 'ee_else_ce/clusters/mixins/track_uninstall_button_click'; -import { HELM, INGRESS, CERT_MANAGER, PROMETHEUS, RUNNER, KNATIVE, JUPYTER } from '../constants'; +import { + HELM, + INGRESS, + CERT_MANAGER, + PROMETHEUS, + RUNNER, + KNATIVE, + JUPYTER, + ELASTIC_STACK, +} from '../constants'; const CUSTOM_APP_WARNING_TEXT = { [HELM]: sprintf( @@ -28,6 +37,7 @@ const CUSTOM_APP_WARNING_TEXT = { [JUPYTER]: s__( 'ClusterIntegration|All data not committed to GitLab will be deleted and cannot be restored.', ), + [ELASTIC_STACK]: s__('ClusterIntegration|All data will be deleted and cannot be restored.'), }; export default { diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index c6e4b7951cf..9f98f170fb0 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -50,8 +50,19 @@ export const JUPYTER = 'jupyter'; export const KNATIVE = 'knative'; export const RUNNER = 'runner'; export const CERT_MANAGER = 'cert_manager'; +export const CROSSPLANE = 'crossplane'; export const PROMETHEUS = 'prometheus'; +export const ELASTIC_STACK = 'elastic_stack'; -export const APPLICATIONS = [HELM, INGRESS, JUPYTER, KNATIVE, RUNNER, CERT_MANAGER, PROMETHEUS]; +export const APPLICATIONS = [ + HELM, + INGRESS, + JUPYTER, + KNATIVE, + RUNNER, + CERT_MANAGER, + PROMETHEUS, + ELASTIC_STACK, +]; 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 fa12802b3de..333fb293a15 100644 --- a/app/assets/javascripts/clusters/services/clusters_service.js +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -7,10 +7,12 @@ export default class ClusterService { helm: this.options.installHelmEndpoint, ingress: this.options.installIngressEndpoint, cert_manager: this.options.installCertManagerEndpoint, + crossplane: this.options.installCrossplaneEndpoint, runner: this.options.installRunnerEndpoint, prometheus: this.options.installPrometheusEndpoint, jupyter: this.options.installJupyterEndpoint, knative: this.options.installKnativeEndpoint, + elastic_stack: this.options.installElasticStackEndpoint, }; 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 6464461ea0c..35dbf951551 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -5,6 +5,8 @@ import { JUPYTER, KNATIVE, CERT_MANAGER, + ELASTIC_STACK, + CROSSPLANE, RUNNER, APPLICATION_INSTALLED_STATUSES, APPLICATION_STATUS, @@ -25,6 +27,7 @@ const applicationInitialState = { uninstallable: false, uninstallFailed: false, uninstallSuccessful: false, + validationError: null, }; export default class ClusterStore { @@ -57,6 +60,11 @@ export default class ClusterStore { title: s__('ClusterIntegration|Cert-Manager'), email: null, }, + crossplane: { + ...applicationInitialState, + title: s__('ClusterIntegration|Crossplane'), + stack: null, + }, runner: { ...applicationInitialState, title: s__('ClusterIntegration|GitLab Runner'), @@ -85,6 +93,11 @@ export default class ClusterStore { updateSuccessful: false, updateFailed: false, }, + elastic_stack: { + ...applicationInitialState, + title: s__('ClusterIntegration|Elastic Stack'), + kibana_hostname: null, + }, }, environments: [], fetchingEnvironments: false, @@ -197,13 +210,15 @@ export default class ClusterStore { } else if (appId === CERT_MANAGER) { this.state.applications.cert_manager.email = this.state.applications.cert_manager.email || serverAppEntry.email; + } else if (appId === CROSSPLANE) { + this.state.applications.crossplane.stack = + this.state.applications.crossplane.stack || serverAppEntry.stack; } else if (appId === JUPYTER) { - this.state.applications.jupyter.hostname = - this.state.applications.jupyter.hostname || - serverAppEntry.hostname || - (this.state.applications.ingress.externalIp - ? `jupyter.${this.state.applications.ingress.externalIp}.nip.io` - : ''); + this.state.applications.jupyter.hostname = this.updateHostnameIfUnset( + this.state.applications.jupyter.hostname, + serverAppEntry.hostname, + 'jupyter', + ); } else if (appId === KNATIVE) { if (!this.state.applications.knative.isEditingHostName) { this.state.applications.knative.hostname = @@ -216,10 +231,26 @@ export default class ClusterStore { } else if (appId === RUNNER) { this.state.applications.runner.version = version; this.state.applications.runner.updateAvailable = updateAvailable; + } else if (appId === ELASTIC_STACK) { + this.state.applications.elastic_stack.kibana_hostname = this.updateHostnameIfUnset( + this.state.applications.elastic_stack.kibana_hostname, + serverAppEntry.kibana_hostname, + 'kibana', + ); } }); } + updateHostnameIfUnset(current, updated, fallback) { + return ( + current || + updated || + (this.state.applications.ingress.externalIp + ? `${fallback}.${this.state.applications.ingress.externalIp}.nip.io` + : '') + ); + } + toggleFetchEnvironments(isFetching) { this.state.fetchingEnvironments = isFetching; } diff --git a/app/assets/javascripts/commit/image_file.js b/app/assets/javascripts/commit/image_file.js index 6c04e0beb4d..60c2059a876 100644 --- a/app/assets/javascripts/commit/image_file.js +++ b/app/assets/javascripts/commit/image_file.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign, no-unused-expressions, no-sequences */ +/* eslint-disable func-names, no-var, no-else-return, consistent-return, one-var, no-return-assign */ import $ from 'jquery'; @@ -9,40 +9,29 @@ const viewModes = ['two-up', 'swipe']; export default class ImageFile { constructor(file) { this.file = file; - this.requestImageInfo( - $('.two-up.view .frame.deleted img', this.file), - (function(_this) { - return function() { - return _this.requestImageInfo($('.two-up.view .frame.added img', _this.file), () => { - _this.initViewModes(); - - // Load two-up view after images are loaded - // so that we can display the correct width and height information - const $images = $('.two-up.view img', _this.file); - - $images.waitForImages(() => { - _this.initView('two-up'); - }); - }); - }; - })(this), + this.requestImageInfo($('.two-up.view .frame.deleted img', this.file), () => + this.requestImageInfo($('.two-up.view .frame.added img', this.file), () => { + this.initViewModes(); + + // Load two-up view after images are loaded + // so that we can display the correct width and height information + const $images = $('.two-up.view img', this.file); + + $images.waitForImages(() => { + this.initView('two-up'); + }); + }), ); } initViewModes() { const viewMode = viewModes[0]; $('.view-modes', this.file).removeClass('hide'); - $('.view-modes-menu', this.file).on( - 'click', - 'li', - (function(_this) { - return function(event) { - if (!$(event.currentTarget).hasClass('active')) { - return _this.activateViewMode(event.currentTarget.className); - } - }; - })(this), - ); + $('.view-modes-menu', this.file).on('click', 'li', event => { + if (!$(event.currentTarget).hasClass('active')) { + return this.activateViewMode(event.currentTarget.className); + } + }); return this.activateViewMode(viewMode); } @@ -51,15 +40,10 @@ export default class ImageFile { .removeClass('active') .filter(`.${viewMode}`) .addClass('active'); - return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut( - 200, - (function(_this) { - return function() { - $(`.view.${viewMode}`, _this.file).fadeIn(200); - return _this.initView(viewMode); - }; - })(this), - ); + return $(`.view:visible:not(.${viewMode})`, this.file).fadeOut(200, () => { + $(`.view.${viewMode}`, this.file).fadeIn(200); + return this.initView(viewMode); + }); } initView(viewMode) { @@ -103,22 +87,18 @@ export default class ImageFile { .on('touchmove', dragMove); } - prepareFrames(view) { + static prepareFrames(view) { var maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; $('.frame', view) - .each( - (function() { - return function(index, frame) { - var height, width; - width = $(frame).width(); - height = $(frame).height(); - maxWidth = width > maxWidth ? width : maxWidth; - return (maxHeight = height > maxHeight ? height : maxHeight); - }; - })(this), - ) + .each((index, frame) => { + var height, width; + width = $(frame).width(); + height = $(frame).height(); + maxWidth = width > maxWidth ? width : maxWidth; + return (maxHeight = height > maxHeight ? height : maxHeight); + }) .css({ width: maxWidth, height: maxHeight, @@ -128,104 +108,95 @@ export default class ImageFile { views = { 'two-up': function() { - return $('.two-up.view .wrap', this.file).each( - (function(_this) { - return function(index, wrap) { - $('img', wrap).each(function() { - var currentWidth; - currentWidth = $(this).width(); - if (currentWidth > availWidth / 2) { - return $(this).width(availWidth / 2); - } - }); - return _this.requestImageInfo($('img', wrap), (width, height) => { - $('.image-info .meta-width', wrap).text(`${width}px`); - $('.image-info .meta-height', wrap).text(`${height}px`); - return $('.image-info', wrap).removeClass('hide'); - }); - }; - })(this), - ); + return $('.two-up.view .wrap', this.file).each((index, wrap) => { + $('img', wrap).each(function() { + var currentWidth; + currentWidth = $(this).width(); + if (currentWidth > availWidth / 2) { + return $(this).width(availWidth / 2); + } + }); + return this.requestImageInfo($('img', wrap), (width, height) => { + $('.image-info .meta-width', wrap).text(`${width}px`); + $('.image-info .meta-height', wrap).text(`${height}px`); + return $('.image-info', wrap).removeClass('hide'); + }); + }); }, swipe() { var maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; - return $('.swipe.view', this.file).each( - (function(_this) { - return function(index, view) { - var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref; - (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref); - $swipeFrame = $('.swipe-frame', view); - $swipeWrap = $('.swipe-wrap', view); - $swipeBar = $('.swipe-bar', view); - - $swipeFrame.css({ - width: maxWidth + 16, - height: maxHeight + 28, - }); - $swipeWrap.css({ - width: maxWidth + 1, - height: maxHeight + 2, - }); - // Set swipeBar left position to match image frame - $swipeBar.css({ - left: 1, - }); - - wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); - - _this.initDraggable($swipeBar, wrapPadding, (e, left) => { - if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { - $swipeWrap.width(maxWidth + 1 - left); - $swipeBar.css('left', left); - } - }); - }; - })(this), - ); + return $('.swipe.view', this.file).each((index, view) => { + var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding; + const ref = ImageFile.prepareFrames(view); + [maxWidth, maxHeight] = ref; + $swipeFrame = $('.swipe-frame', view); + $swipeWrap = $('.swipe-wrap', view); + $swipeBar = $('.swipe-bar', view); + + $swipeFrame.css({ + width: maxWidth + 16, + height: maxHeight + 28, + }); + $swipeWrap.css({ + width: maxWidth + 1, + height: maxHeight + 2, + }); + // Set swipeBar left position to match image frame + $swipeBar.css({ + left: 1, + }); + + wrapPadding = parseInt($swipeWrap.css('right').replace('px', ''), 10); + + this.initDraggable($swipeBar, wrapPadding, (e, left) => { + if (left > 0 && left < $swipeFrame.width() - wrapPadding * 2) { + $swipeWrap.width(maxWidth + 1 - left); + $swipeBar.css('left', left); + } + }); + }); }, 'onion-skin': function() { var dragTrackWidth, maxHeight, maxWidth; maxWidth = 0; maxHeight = 0; dragTrackWidth = $('.drag-track', this.file).width() - $('.dragger', this.file).width(); - return $('.onion-skin.view', this.file).each( - (function(_this) { - return function(index, view) { - var $frame, $track, $dragger, $frameAdded, framePadding, ref; - (ref = _this.prepareFrames(view)), ([maxWidth, maxHeight] = ref); - $frame = $('.onion-skin-frame', view); - $frameAdded = $('.frame.added', view); - $track = $('.drag-track', view); - $dragger = $('.dragger', $track); - - $frame.css({ - width: maxWidth + 16, - height: maxHeight + 28, - }); - $('.swipe-wrap', view).css({ - width: maxWidth + 1, - height: maxHeight + 2, - }); - $dragger.css({ - left: dragTrackWidth, - }); - - $frameAdded.css('opacity', 1); - framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); - - _this.initDraggable($dragger, framePadding, (e, left) => { - var opacity = left / dragTrackWidth; - - if (opacity >= 0 && opacity <= 1) { - $dragger.css('left', left); - $frameAdded.css('opacity', opacity); - } - }); - }; - })(this), - ); + return $('.onion-skin.view', this.file).each((index, view) => { + var $frame, $track, $dragger, $frameAdded, framePadding; + + const ref = ImageFile.prepareFrames(view); + [maxWidth, maxHeight] = ref; + $frame = $('.onion-skin-frame', view); + $frameAdded = $('.frame.added', view); + $track = $('.drag-track', view); + $dragger = $('.dragger', $track); + + $frame.css({ + width: maxWidth + 16, + height: maxHeight + 28, + }); + $('.swipe-wrap', view).css({ + width: maxWidth + 1, + height: maxHeight + 2, + }); + $dragger.css({ + left: dragTrackWidth, + }); + + $frameAdded.css('opacity', 1); + framePadding = parseInt($frameAdded.css('right').replace('px', ''), 10); + + this.initDraggable($dragger, framePadding, (e, left) => { + var opacity = left / dragTrackWidth; + + if (opacity >= 0 && opacity <= 1) { + $dragger.css('left', left); + $frameAdded.css('opacity', opacity); + } + }); + }); }, }; @@ -235,14 +206,7 @@ export default class ImageFile { if (domImg.complete) { return callback.call(this, domImg.naturalWidth, domImg.naturalHeight); } else { - return img.on( - 'load', - (function(_this) { - return function() { - return callback.call(_this, domImg.naturalWidth, domImg.naturalHeight); - }; - })(this), - ); + return img.on('load', () => callback.call(this, domImg.naturalWidth, domImg.naturalHeight)); } } } diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 81ba15577fb..a23707209dc 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, one-var, no-var, no-else-return */ +/* eslint-disable func-names, no-else-return */ import $ from 'jquery'; import { __ } from './locale'; @@ -8,9 +8,8 @@ import { capitalizeFirstCharacter } from './lib/utils/text_utility'; export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { $('.js-compare-dropdown').each(function() { - var $dropdown, selected; - $dropdown = $(this); - selected = $dropdown.data('selected'); + const $dropdown = $(this); + const selected = $dropdown.data('selected'); const $dropdownContainer = $dropdown.closest('.dropdown'); const $fieldInput = $(`input[name="${$dropdown.data('fieldName')}"]`, $dropdownContainer); const $filterInput = $('input[type="search"]', $dropdownContainer); @@ -44,17 +43,16 @@ export default function initCompareAutocomplete(limitTo = null, clickHandler = ( fieldName: $dropdown.data('fieldName'), filterInput: 'input[type="search"]', renderRow(ref) { - var link; + const link = $('<a />') + .attr('href', '#') + .addClass(ref === selected ? 'is-active' : '') + .text(ref) + .attr('data-ref', ref); if (ref.header != null) { return $('<li />') .addClass('dropdown-header') .text(ref.header); } else { - link = $('<a />') - .attr('href', '#') - .addClass(ref === selected ? 'is-active' : '') - .text(ref) - .attr('data-ref', ref); return $('<li />').append(link); } }, diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index 197a0706062..4fa18b19556 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -41,7 +41,7 @@ export default { noForkText() { return sprintf( __( - "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visiblity to private.", + "To protect this issue's confidentiality, %{link_start}fork the project%{link_end} and set the forks visibility to private.", ), { link_start: `<a href="${this.newForkPath}" class="help-link">`, link_end: '</a>' }, false, diff --git a/app/assets/javascripts/contributors/components/contributors.vue b/app/assets/javascripts/contributors/components/contributors.vue new file mode 100644 index 00000000000..7dd6b051cb4 --- /dev/null +++ b/app/assets/javascripts/contributors/components/contributors.vue @@ -0,0 +1,227 @@ +<script> +import { __ } from '~/locale'; +import _ from 'underscore'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import { getDatesInRange } from '~/lib/utils/datetime_utility'; +import { xAxisLabelFormatter, dateFormatter } from '../utils'; + +export default { + components: { + GlAreaChart, + GlLoadingIcon, + }, + props: { + endpoint: { + type: String, + required: true, + }, + branch: { + type: String, + required: true, + }, + }, + data() { + return { + masterChart: null, + individualCharts: [], + svgs: {}, + masterChartHeight: 264, + individualChartHeight: 216, + }; + }, + computed: { + ...mapState(['chartData', 'loading']), + ...mapGetters(['showChart', 'parsedData']), + masterChartData() { + const data = {}; + this.xAxisRange.forEach(date => { + data[date] = this.parsedData.total[date] || 0; + }); + return [ + { + name: __('Commits'), + data: Object.entries(data), + }, + ]; + }, + masterChartOptions() { + return { + ...this.getCommonChartOptions(true), + yAxis: { + name: __('Number of commits'), + }, + grid: { + bottom: 64, + left: 64, + right: 20, + top: 20, + }, + }; + }, + individualChartsData() { + const maxNumberOfIndividualContributorsCharts = 100; + + return Object.keys(this.parsedData.byAuthor) + .map(name => { + const author = this.parsedData.byAuthor[name]; + return { + name, + email: author.email, + commits: author.commits, + dates: [ + { + name: __('Commits'), + data: this.xAxisRange.map(date => [date, author.dates[date] || 0]), + }, + ], + }; + }) + .sort((a, b) => b.commits - a.commits) + .slice(0, maxNumberOfIndividualContributorsCharts); + }, + individualChartOptions() { + return { + ...this.getCommonChartOptions(false), + yAxis: { + name: __('Commits'), + max: this.individualChartYAxisMax, + }, + grid: { + bottom: 27, + left: 64, + right: 20, + top: 8, + }, + }; + }, + individualChartYAxisMax() { + return this.individualChartsData.reduce((acc, item) => { + const values = item.dates[0].data.map(value => value[1]); + return Math.max(acc, ...values); + }, 0); + }, + xAxisRange() { + const dates = Object.keys(this.parsedData.total).sort((a, b) => new Date(a) - new Date(b)); + + const firstContributionDate = new Date(dates[0]); + const lastContributionDate = new Date(dates[dates.length - 1]); + + return getDatesInRange(firstContributionDate, lastContributionDate, dateFormatter); + }, + firstContributionDate() { + return this.xAxisRange[0]; + }, + lastContributionDate() { + return this.xAxisRange[this.xAxisRange.length - 1]; + }, + charts() { + return _.uniq(this.individualCharts); + }, + }, + mounted() { + this.fetchChartData(this.endpoint); + }, + methods: { + ...mapActions(['fetchChartData']), + getCommonChartOptions(isMasterChart) { + return { + xAxis: { + type: 'time', + name: '', + data: this.xAxisRange, + axisLabel: { + formatter: xAxisLabelFormatter, + showMaxLabel: false, + showMinLabel: false, + }, + boundaryGap: false, + splitNumber: isMasterChart ? 24 : 18, + // 28 days + minInterval: 28 * 86400 * 1000, + min: this.firstContributionDate, + max: this.lastContributionDate, + }, + }; + }, + setSvg(name) { + return getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(() => {}); + }, + onMasterChartCreated(chart) { + this.masterChart = chart; + this.setSvg('scroll-handle') + .then(() => { + this.masterChart.setOption({ + dataZoom: [ + { + type: 'slider', + handleIcon: this.svgs['scroll-handle'], + }, + ], + }); + }) + .catch(() => {}); + this.masterChart.on('datazoom', _.debounce(this.setIndividualChartsZoom, 200)); + }, + onIndividualChartCreated(chart) { + this.individualCharts.push(chart); + }, + setIndividualChartsZoom(options) { + this.charts.forEach(chart => + chart.setOption( + { + dataZoom: { + start: options.start, + end: options.end, + show: false, + }, + }, + { lazyUpdate: true }, + ), + ); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="loading" class="contributors-loader text-center"> + <gl-loading-icon :inline="true" :size="4" /> + </div> + + <div v-else-if="showChart" class="contributors-charts"> + <h4>{{ __('Commits to') }} {{ branch }}</h4> + <span>{{ __('Excluding merge commits. Limited to 6,000 commits.') }}</span> + <div> + <gl-area-chart + :data="masterChartData" + :option="masterChartOptions" + :height="masterChartHeight" + @created="onMasterChartCreated" + /> + </div> + + <div class="row"> + <div v-for="contributor in individualChartsData" :key="contributor.name" class="col-6"> + <h4>{{ contributor.name }}</h4> + <p>{{ n__('%d commit', '%d commits', contributor.commits) }} ({{ contributor.email }})</p> + <gl-area-chart + :data="contributor.dates" + :option="individualChartOptions" + :height="individualChartHeight" + @created="onIndividualChartCreated" + /> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/contributors/index.js b/app/assets/javascripts/contributors/index.js new file mode 100644 index 00000000000..b6063589734 --- /dev/null +++ b/app/assets/javascripts/contributors/index.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import ContributorsGraphs from './components/contributors.vue'; +import store from './stores'; + +export default () => { + const el = document.querySelector('.js-contributors-graph'); + + if (!el) return null; + + return new Vue({ + el, + store, + + render(createElement) { + return createElement(ContributorsGraphs, { + props: { + endpoint: el.dataset.projectGraphPath, + branch: el.dataset.projectBranch, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/contributors/services/contributors_service.js b/app/assets/javascripts/contributors/services/contributors_service.js new file mode 100644 index 00000000000..5a8bbb66511 --- /dev/null +++ b/app/assets/javascripts/contributors/services/contributors_service.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +export default { + fetchChartData(endpoint) { + return axios.get(endpoint); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js new file mode 100644 index 00000000000..4138ff24f1d --- /dev/null +++ b/app/assets/javascripts/contributors/stores/actions.js @@ -0,0 +1,20 @@ +import flash from '~/flash'; +import { __ } from '~/locale'; +import service from '../services/contributors_service'; +import * as types from './mutation_types'; + +export const fetchChartData = ({ commit }, endpoint) => { + commit(types.SET_LOADING_STATE, true); + + return service + .fetchChartData(endpoint) + .then(res => res.data) + .then(data => { + commit(types.SET_CHART_DATA, data); + commit(types.SET_LOADING_STATE, false); + }) + .catch(() => flash(__('An error occurred while loading chart data'))); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js new file mode 100644 index 00000000000..9e02e3ed9e7 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -0,0 +1,33 @@ +export const showChart = state => Boolean(!state.loading && state.chartData); + +export const parsedData = state => { + const byAuthor = {}; + const total = {}; + + state.chartData.forEach(({ date, author_name, author_email }) => { + total[date] = total[date] ? total[date] + 1 : 1; + + const authorData = byAuthor[author_name]; + + if (!authorData) { + byAuthor[author_name] = { + email: author_email.toLowerCase(), + commits: 1, + dates: { + [date]: 1, + }, + }; + } else { + authorData.commits += 1; + authorData.dates[date] = authorData.dates[date] ? authorData.dates[date] + 1 : 1; + } + }); + + return { + total, + byAuthor, + }; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/index.js b/app/assets/javascripts/contributors/stores/index.js new file mode 100644 index 00000000000..bc739851aa7 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import mutations from './mutations'; +import * as getters from './getters'; +import * as actions from './actions'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + mutations, + getters, + state: state(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/contributors/stores/mutation_types.js b/app/assets/javascripts/contributors/stores/mutation_types.js new file mode 100644 index 00000000000..62e0a51d5f8 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_CHART_DATA = 'SET_CHART_DATA'; +export const SET_LOADING_STATE = 'SET_LOADING_STATE'; +export const SET_ACTIVE_BRANCH = 'SET_ACTIVE_BRANCH'; diff --git a/app/assets/javascripts/contributors/stores/mutations.js b/app/assets/javascripts/contributors/stores/mutations.js new file mode 100644 index 00000000000..f1f460d072d --- /dev/null +++ b/app/assets/javascripts/contributors/stores/mutations.js @@ -0,0 +1,17 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING_STATE](state, value) { + state.loading = value; + }, + [types.SET_CHART_DATA](state, chartData) { + Object.assign(state, { + chartData, + }); + }, + [types.SET_ACTIVE_BRANCH](state, branch) { + Object.assign(state, { + branch, + }); + }, +}; diff --git a/app/assets/javascripts/contributors/stores/state.js b/app/assets/javascripts/contributors/stores/state.js new file mode 100644 index 00000000000..1dc1a3c7b75 --- /dev/null +++ b/app/assets/javascripts/contributors/stores/state.js @@ -0,0 +1,5 @@ +export default () => ({ + loading: false, + chartData: null, + branch: 'master', +}); diff --git a/app/assets/javascripts/contributors/utils.js b/app/assets/javascripts/contributors/utils.js new file mode 100644 index 00000000000..7d8932ce495 --- /dev/null +++ b/app/assets/javascripts/contributors/utils.js @@ -0,0 +1,30 @@ +import { getMonthNames } from '~/lib/utils/datetime_utility'; + +/** + * Converts provided string to date and returns formatted value as a year for date in January and month name for the rest + * @param {String} + * @returns {String} - formatted value + * + * xAxisLabelFormatter('01-12-2019') will return '2019' + * xAxisLabelFormatter('02-12-2019') will return 'Feb' + * xAxisLabelFormatter('07-12-2019') will return 'Jul' + */ +export const xAxisLabelFormatter = val => { + const date = new Date(val); + const month = date.getUTCMonth(); + const year = date.getUTCFullYear(); + return month === 0 ? `${year}` : getMonthNames(true)[month]; +}; + +/** + * Formats provided date to YYYY-MM-DD format + * @param {Date} + * @returns {String} - formatted value + */ +export const dateFormatter = date => { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const day = date.getUTCDate(); + + return `${year}-${`0${month + 1}`.slice(-2)}-${`0${day}`.slice(-2)}`; +}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue index 3c6da43c4c4..e6893c14cda 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/cluster_form_dropdown.vue @@ -2,14 +2,19 @@ import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; +import { GlIcon } from '@gitlab/ui'; -const findItem = (items, valueProp, value) => items.find(item => item[valueProp] === value); +const toArray = value => [].concat(value); +const itemsProp = (items, prop) => items.map(item => item[prop]); +const defaultSearchFn = (searchQuery, labelProp) => item => + item[labelProp].toLowerCase().indexOf(searchQuery) > -1; export default { components: { DropdownButton, DropdownSearchInput, DropdownHiddenInput, + GlIcon, }, props: { fieldName: { @@ -28,7 +33,7 @@ export default { default: '', }, value: { - type: [Object, String], + type: [Object, Array, String], required: false, default: () => null, }, @@ -72,6 +77,11 @@ export default { required: false, default: false, }, + multiple: { + type: Boolean, + required: false, + default: false, + }, errorMessage: { type: String, required: false, @@ -90,12 +100,11 @@ export default { searchFn: { type: Function, required: false, - default: searchQuery => item => item.name.toLowerCase().indexOf(searchQuery) > -1, + default: defaultSearchFn, }, }, data() { return { - selectedItem: findItem(this.items, this.value), searchQuery: '', }; }, @@ -109,36 +118,52 @@ export default { return this.disabledText; } - if (!this.selectedItem) { + if (!this.selectedItems.length) { return this.placeholder; } - return this.selectedItemLabel; + return this.selectedItemsLabels; }, results() { - if (!this.items) { - return []; - } - - return this.items.filter(this.searchFn(this.searchQuery)); + return this.getItemsOrEmptyList().filter(this.searchFn(this.searchQuery, this.labelProperty)); }, - selectedItemLabel() { - return this.selectedItem && this.selectedItem[this.labelProperty]; + selectedItems() { + const valueProp = this.valueProperty; + const valueList = toArray(this.value); + const items = this.getItemsOrEmptyList(); + + return items.filter(item => valueList.some(value => item[valueProp] === value)); }, - selectedItemValue() { - return (this.selectedItem && this.selectedItem[this.valueProperty]) || ''; + selectedItemsLabels() { + return itemsProp(this.selectedItems, this.labelProperty).join(', '); }, - }, - watch: { - value(value) { - this.selectedItem = findItem(this.items, this.valueProperty, value); + selectedItemsValues() { + return itemsProp(this.selectedItems, this.valueProperty).join(', '); }, }, methods: { - select(item) { - this.selectedItem = item; + getItemsOrEmptyList() { + return this.items || []; + }, + selectSingle(item) { this.$emit('input', item[this.valueProperty]); }, + selectMultiple(item) { + const value = toArray(this.value); + const itemValue = item[this.valueProperty]; + const itemValueIndex = value.indexOf(itemValue); + + if (itemValueIndex > -1) { + value.splice(itemValueIndex, 1); + } else { + value.push(itemValue); + } + + this.$emit('input', value); + }, + isSelected(item) { + return this.selectedItems.includes(item); + }, }, }; </script> @@ -146,7 +171,7 @@ export default { <template> <div> <div class="js-gcp-machine-type-dropdown dropdown"> - <dropdown-hidden-input :name="fieldName" :value="selectedItemValue" /> + <dropdown-hidden-input :name="fieldName" :value="selectedItemsValues" /> <dropdown-button :class="{ 'border-danger': hasErrors }" :is-disabled="disabled" @@ -158,15 +183,28 @@ export default { <div class="dropdown-content"> <ul> <li v-if="!results.length"> - <span class="js-empty-text menu-item"> - {{ emptyText }} - </span> + <span class="js-empty-text menu-item">{{ emptyText }}</span> </li> <li v-for="item in results" :key="item.id"> - <button class="js-dropdown-item" type="button" @click.prevent="select(item)"> - <slot name="item" :item="item"> - {{ item.name }} - </slot> + <button + v-if="multiple" + class="js-dropdown-item d-flex align-items-center" + type="button" + @click.stop.prevent="selectMultiple(item)" + > + <gl-icon + :class="[{ invisible: !isSelected(item) }, 'mr-1']" + name="mobile-issue-close" + /> + <slot name="item" :item="item">{{ item.name }}</slot> + </button> + <button + v-else + class="js-dropdown-item" + type="button" + @click.prevent="selectSingle(item)" + > + <slot name="item" :item="item">{{ item.name }}</slot> </button> </li> </ul> @@ -182,8 +220,7 @@ export default { 'text-muted': !hasErrors, }, ]" + >{{ errorMessage }}</span > - {{ errorMessage }} - </span> </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue index 22ee368b8e0..3f7c2204b9f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/create_eks_cluster.vue @@ -1,4 +1,5 @@ <script> +import { mapState } from 'vuex'; import ServiceCredentialsForm from './service_credentials_form.vue'; import EksClusterConfigurationForm from './eks_cluster_configuration_form.vue'; @@ -16,14 +17,37 @@ export default { type: String, required: true, }, + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['hasCredentials']), }, }; </script> <template> <div class="js-create-eks-cluster"> <eks-cluster-configuration-form + v-if="hasCredentials" :gitlab-managed-cluster-help-path="gitlabManagedClusterHelpPath" :kubernetes-integration-help-path="kubernetesIntegrationHelpPath" + :external-link-icon="externalLinkIcon" + /> + <service-credentials-form + v-else + :create-role-arn-help-path="createRoleArnHelpPath" + :account-and-external-ids-help-path="accountAndExternalIdsHelpPath" + :external-link-icon="externalLinkIcon" /> </div> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue index 1188cf08850..57d5f4f541b 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/eks_cluster_configuration_form.vue @@ -4,8 +4,8 @@ import { sprintf, s__ } from '~/locale'; import _ from 'underscore'; import { GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import ClusterFormDropdown from './cluster_form_dropdown.vue'; -import RegionDropdown from './region_dropdown.vue'; import { KUBERNETES_VERSIONS } from '../constants'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; const { mapState: mapRolesState, mapActions: mapRolesActions } = createNamespacedHelpers('roles'); const { mapState: mapRegionsState, mapActions: mapRegionsActions } = createNamespacedHelpers( @@ -22,13 +22,17 @@ const { mapState: mapSecurityGroupsState, mapActions: mapSecurityGroupsActions, } = createNamespacedHelpers('securityGroups'); +const { + mapState: mapInstanceTypesState, + mapActions: mapInstanceTypesActions, +} = createNamespacedHelpers('instanceTypes'); export default { components: { ClusterFormDropdown, - RegionDropdown, GlFormInput, GlFormCheckbox, + LoadingButton, }, props: { gitlabManagedClusterHelpPath: { @@ -39,6 +43,10 @@ export default { type: String, required: true, }, + externalLinkIcon: { + type: String, + required: true, + }, }, computed: { ...mapState([ @@ -51,7 +59,10 @@ export default { 'selectedSubnet', 'selectedRole', 'selectedSecurityGroup', + 'selectedInstanceType', + 'nodeCount', 'gitlabManagedCluster', + 'isCreatingCluster', ]), ...mapRolesState({ roles: 'items', @@ -83,6 +94,11 @@ export default { isLoadingSecurityGroups: 'isLoadingItems', loadingSecurityGroupsError: 'loadingItemsError', }), + ...mapInstanceTypesState({ + instanceTypes: 'items', + isLoadingInstanceTypes: 'isLoadingItems', + loadingInstanceTypesError: 'loadingItemsError', + }), kubernetesVersions() { return KUBERNETES_VERSIONS; }, @@ -98,6 +114,27 @@ export default { securityGroupDropdownDisabled() { return !this.selectedVpc; }, + createClusterButtonDisabled() { + return ( + !this.clusterName || + !this.environmentScope || + !this.kubernetesVersion || + !this.selectedRegion || + !this.selectedKeyPair || + !this.selectedVpc || + !this.selectedSubnet || + !this.selectedRole || + !this.selectedSecurityGroup || + !this.selectedInstanceType || + !this.nodeCount || + this.isCreatingCluster + ); + }, + createClusterButtonLabel() { + return this.isCreatingCluster + ? s__('ClusterIntegration|Creating Kubernetes cluster') + : s__('ClusterIntegration|Create Kubernetes cluster'); + }, kubernetesIntegrationHelpText() { const escapedUrl = _.escape(this.kubernetesIntegrationHelpPath); @@ -115,11 +152,26 @@ export default { roleDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select the IAM Role to allow Amazon EKS and the Kubernetes control plane to manage AWS resources on your behalf. To use a new role name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', + ), + { + startLink: + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#role-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + regionsDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Learn more about %{startLink}Regions %{externalLinkIcon}%{endLink}.', ), { startLink: - '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -128,11 +180,12 @@ export default { keyPairDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select the key pair name that will be used to create EC2 nodes. To use a new key pair name, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: '<a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html#having-ec2-create-your-key-pair" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -141,11 +194,12 @@ export default { vpcDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services%{endLink}.', + 'ClusterIntegration|Select a VPC to use for your EKS Cluster resources. To use a new VPC, first create one on %{startLink}Amazon Web Services %{externalLinkIcon} %{endLink}.', ), { startLink: - '<a href="https://console.aws.amazon.com/vpc/home?#vpc" target="_blank" rel="noopener noreferrer">', + '<a href="https://docs.aws.amazon.com/eks/latest/userguide/getting-started-console.html#vpc-create" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -154,11 +208,12 @@ export default { subnetDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}subnets%{endLink} in your VPC where your worker nodes will run.', + 'ClusterIntegration|Choose the %{startLink}subnets %{externalLinkIcon} %{endLink} in your VPC where your worker nodes will run.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#subnets" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -167,11 +222,26 @@ export default { securityGroupDropdownHelpText() { return sprintf( s__( - 'ClusterIntegration|Choose the %{startLink}security groups%{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', + 'ClusterIntegration|Choose the %{startLink}security group %{externalLinkIcon} %{endLink} to apply to the EKS-managed Elastic Network Interfaces that are created in your worker node subnets.', ), { startLink: '<a href="https://console.aws.amazon.com/vpc/home?#securityGroups" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + instanceTypesDropdownHelpText() { + return sprintf( + s__( + 'ClusterIntegration|Choose the worker node %{startLink}instance type %{externalLinkIcon} %{endLink}.', + ), + { + startLink: + '<a href="https://aws.amazon.com/ec2/instance-types" target="_blank" rel="noopener noreferrer">', + externalLinkIcon: this.externalLinkIcon, endLink: '</a>', }, false, @@ -195,9 +265,12 @@ export default { mounted() { this.fetchRegions(); this.fetchRoles(); + this.fetchInstanceTypes(); }, methods: { ...mapActions([ + 'createCluster', + 'signOut', 'setClusterName', 'setEnvironmentScope', 'setKubernetesVersion', @@ -207,6 +280,8 @@ export default { 'setRole', 'setKeyPair', 'setSecurityGroup', + 'setInstanceType', + 'setNodeCount', 'setGitlabManagedCluster', ]), ...mapRegionsActions({ fetchRegions: 'fetchItems' }), @@ -215,15 +290,22 @@ export default { ...mapRolesActions({ fetchRoles: 'fetchItems' }), ...mapKeyPairsActions({ fetchKeyPairs: 'fetchItems' }), ...mapSecurityGroupsActions({ fetchSecurityGroups: 'fetchItems' }), + ...mapInstanceTypesActions({ fetchInstanceTypes: 'fetchItems' }), setRegionAndFetchVpcsAndKeyPairs(region) { this.setRegion({ region }); + this.setVpc({ vpc: null }); + this.setKeyPair({ keyPair: null }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); this.fetchVpcs({ region }); this.fetchKeyPairs({ region }); }, setVpcAndFetchSubnets(vpc) { this.setVpc({ vpc }); - this.fetchSubnets({ vpc }); - this.fetchSecurityGroups({ vpc }); + this.setSubnet({ subnet: null }); + this.setSecurityGroup({ securityGroup: null }); + this.fetchSubnets({ vpc, region: this.selectedRegion }); + this.fetchSecurityGroups({ vpc, region: this.selectedRegion }); }, }, }; @@ -233,7 +315,12 @@ export default { <h2> {{ s__('ClusterIntegration|Enter the details for your Amazon EKS Kubernetes cluster') }} </h2> - <p v-html="kubernetesIntegrationHelpText"></p> + <div class="mb-3" v-html="kubernetesIntegrationHelpText"></div> + <div class="mb-3"> + <button class="btn btn-link js-sign-out" @click.prevent="signOut()"> + {{ s__('ClusterIntegration|Select a different AWS role') }} + </button> + </div> <div class="form-group"> <label class="label-bold" for="eks-cluster-name">{{ s__('ClusterIntegration|Kubernetes cluster name') @@ -273,7 +360,7 @@ export default { <cluster-form-dropdown field-id="eks-role" field-name="eks-role" - :input="selectedRole" + :value="selectedRole" :items="roles" :loading="isLoadingRoles" :loading-text="s__('ClusterIntegration|Loading IAM Roles')" @@ -288,13 +375,21 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Region') }}</label> - <region-dropdown + <cluster-form-dropdown + field-id="eks-region" + field-name="eks-region" :value="selectedRegion" - :regions="regions" - :error="loadingRegionsError" + :items="regions" :loading="isLoadingRegions" + :loading-text="s__('ClusterIntegration|Loading Regions')" + :placeholder="s__('ClusterIntergation|Select a region')" + :search-field-placeholder="s__('ClusterIntegration|Search regions')" + :empty-text="s__('ClusterIntegration|No region found')" + :has-errors="Boolean(loadingRegionsError)" + :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" @input="setRegionAndFetchVpcsAndKeyPairs($event)" /> + <p class="form-text text-muted" v-html="regionsDropdownHelpText"></p> </div> <div class="form-group"> <label class="label-bold" for="eks-key-pair">{{ @@ -303,7 +398,7 @@ export default { <cluster-form-dropdown field-id="eks-key-pair" field-name="eks-key-pair" - :input="selectedKeyPair" + :value="selectedKeyPair" :items="keyPairs" :disabled="keyPairDropdownDisabled" :disabled-text="s__('ClusterIntegration|Select a region to choose a Key Pair')" @@ -323,7 +418,7 @@ export default { <cluster-form-dropdown field-id="eks-vpc" field-name="eks-vpc" - :input="selectedVpc" + :value="selectedVpc" :items="vpcs" :loading="isLoadingVpcs" :disabled="vpcDropdownDisabled" @@ -339,11 +434,12 @@ export default { <p class="form-text text-muted" v-html="vpcDropdownHelpText"></p> </div> <div class="form-group"> - <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnet') }}</label> + <label class="label-bold" for="eks-role">{{ s__('ClusterIntegration|Subnets') }}</label> <cluster-form-dropdown field-id="eks-subnet" field-name="eks-subnet" - :input="selectedSubnet" + multiple + :value="selectedSubnet" :items="subnets" :loading="isLoadingSubnets" :disabled="subnetDropdownDisabled" @@ -360,12 +456,12 @@ export default { </div> <div class="form-group"> <label class="label-bold" for="eks-security-group">{{ - s__('ClusterIntegration|Security groups') + s__('ClusterIntegration|Security group') }}</label> <cluster-form-dropdown field-id="eks-security-group" field-name="eks-security-group" - :input="selectedSecurityGroup" + :value="selectedSecurityGroup" :items="securityGroups" :loading="isLoadingSecurityGroups" :disabled="securityGroupDropdownDisabled" @@ -383,6 +479,39 @@ export default { <p class="form-text text-muted" v-html="securityGroupDropdownHelpText"></p> </div> <div class="form-group"> + <label class="label-bold" for="eks-instance-type">{{ + s__('ClusterIntegration|Instance type') + }}</label> + <cluster-form-dropdown + field-id="eks-instance-type" + field-name="eks-instance-type" + :value="selectedInstanceType" + :items="instanceTypes" + :loading="isLoadingInstanceTypes" + :loading-text="s__('ClusterIntegration|Loading instance types')" + :placeholder="s__('ClusterIntergation|Select an instance type')" + :search-field-placeholder="s__('ClusterIntegration|Search instance types')" + :empty-text="s__('ClusterIntegration|No instance type found')" + :has-errors="Boolean(loadingInstanceTypesError)" + :error-message="s__('ClusterIntegration|Could not load instance types')" + @input="setInstanceType({ instanceType: $event })" + /> + <p class="form-text text-muted" v-html="instanceTypesDropdownHelpText"></p> + </div> + <div class="form-group"> + <label class="label-bold" for="eks-node-count">{{ + s__('ClusterIntegration|Number of nodes') + }}</label> + <gl-form-input + id="eks-node-count" + type="number" + min="1" + step="1" + :value="nodeCount" + @input="setNodeCount({ nodeCount: $event })" + /> + </div> + <div class="form-group"> <gl-form-checkbox :checked="gitlabManagedCluster" @input="setGitlabManagedCluster({ gitlabManagedCluster: $event })" @@ -390,5 +519,14 @@ export default { > <p class="form-text text-muted" v-html="gitlabManagedHelpText"></p> </div> + <div class="form-group"> + <loading-button + class="js-create-cluster btn-success" + :disabled="createClusterButtonDisabled" + :loading="isCreatingCluster" + :label="createClusterButtonLabel" + @click="createCluster()" + /> + </div> </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue deleted file mode 100644 index 765955305c8..00000000000 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/region_dropdown.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { sprintf, s__ } from '~/locale'; - -import ClusterFormDropdown from './cluster_form_dropdown.vue'; - -export default { - components: { - ClusterFormDropdown, - }, - props: { - regions: { - type: Array, - required: false, - default: () => [], - }, - loading: { - type: Boolean, - required: false, - default: false, - }, - error: { - type: Object, - required: false, - default: null, - }, - }, - computed: { - hasErrors() { - return Boolean(this.error); - }, - helpText() { - return sprintf( - s__('ClusterIntegration|Learn more about %{startLink}Regions%{endLink}.'), - { - startLink: - '<a href="https://aws.amazon.com/about-aws/global-infrastructure/regional-product-services/" target="_blank" rel="noopener noreferrer">', - endLink: '</a>', - }, - false, - ); - }, - }, -}; -</script> -<template> - <div> - <cluster-form-dropdown - field-id="eks-region" - field-name="eks-region" - :items="regions" - :loading="loading" - :loading-text="s__('ClusterIntegration|Loading Regions')" - :placeholder="s__('ClusterIntergation|Select a region')" - :search-field-placeholder="s__('ClusterIntegration|Search regions')" - :empty-text="s__('ClusterIntegration|No region found')" - :has-errors="hasErrors" - :error-message="s__('ClusterIntegration|Could not load regions from your AWS account')" - v-bind="$attrs" - v-on="$listeners" - /> - <p class="form-text text-muted" v-html="helpText"></p> - </div> -</template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue index 79029b8cfa8..ab33e9fbc95 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue +++ b/app/assets/javascripts/create_cluster/eks_cluster/components/service_credentials_form.vue @@ -1,3 +1,141 @@ +<script> +import { GlFormInput } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import _ from 'underscore'; +import { mapState, mapActions } from 'vuex'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; + +export default { + components: { + GlFormInput, + LoadingButton, + ClipboardButton, + }, + props: { + accountAndExternalIdsHelpPath: { + type: String, + required: true, + }, + createRoleArnHelpPath: { + type: String, + required: true, + }, + externalLinkIcon: { + type: String, + required: true, + }, + }, + data() { + return { + roleArn: '', + }; + }, + computed: { + ...mapState(['accountId', 'externalId', 'isCreatingRole', 'createRoleError']), + submitButtonDisabled() { + return this.isCreatingRole || !this.roleArn; + }, + submitButtonLabel() { + return this.isCreatingRole + ? __('Authenticating') + : s__('ClusterIntegration|Authenticate with AWS'); + }, + accountAndExternalIdsHelpText() { + const escapedUrl = _.escape(this.accountAndExternalIdsHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|Create a provision role on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the account and external ID above. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + provisionRoleArnHelpText() { + const escapedUrl = _.escape(this.createRoleArnHelpPath); + + return sprintf( + s__( + 'ClusterIntegration|The Amazon Resource Name (ARN) associated with your role. If you do not have a provision role, first create one on %{startAwsLink}Amazon Web Services %{externalLinkIcon}%{endLink} using the above account and external IDs. %{startMoreInfoLink}More information%{endLink}', + ), + { + startAwsLink: + '<a href="https://console.aws.amazon.com/iam/home?#roles" target="_blank" rel="noopener noreferrer">', + startMoreInfoLink: `<a href="${escapedUrl}" target="_blank" rel="noopener noreferrer">`, + externalLinkIcon: this.externalLinkIcon, + endLink: '</a>', + }, + false, + ); + }, + }, + methods: { + ...mapActions(['createRole']), + }, +}; +</script> <template> - <form name="service-credentials-form"></form> + <form name="service-credentials-form" @submit.prevent="createRole({ roleArn, externalId })"> + <h2>{{ s__('ClusterIntegration|Authenticate with Amazon Web Services') }}</h2> + <p> + {{ + s__( + 'ClusterIntegration|You must grant access to your organization’s AWS resources in order to create a new EKS cluster. To grant access, create a provision role using the account and external ID below and provide us the ARN.', + ) + }} + </p> + <div v-if="createRoleError" class="js-invalid-credentials bs-callout bs-callout-danger"> + {{ createRoleError }} + </div> + <div class="form-row"> + <div class="form-group col-md-6"> + <label for="gitlab-account-id">{{ __('Account ID') }}</label> + <div class="input-group"> + <gl-form-input id="gitlab-account-id" type="text" readonly :value="accountId" /> + <div class="input-group-append"> + <clipboard-button + :text="accountId" + :title="__('Copy Account ID to clipboard')" + class="input-group-text js-copy-account-id-button" + /> + </div> + </div> + </div> + <div class="form-group col-md-6"> + <label for="eks-external-id">{{ __('External ID') }}</label> + <div class="input-group"> + <gl-form-input id="eks-external-id" type="text" readonly :value="externalId" /> + <div class="input-group-append"> + <clipboard-button + :text="externalId" + :title="__('Copy External ID to clipboard')" + class="input-group-text js-copy-external-id-button" + /> + </div> + </div> + </div> + <div class="col-12 mb-3 mt-n3"> + <p class="form-text text-muted" v-html="accountAndExternalIdsHelpText"></p> + </div> + </div> + <div class="form-group"> + <label for="eks-provision-role-arn">{{ s__('ClusterIntegration|Provision Role ARN') }}</label> + <gl-form-input id="eks-provision-role-arn" v-model="roleArn" /> + <p class="form-text text-muted" v-html="provisionRoleArnHelpText"></p> + </div> + <loading-button + class="js-submit-service-credentials btn-success" + type="submit" + :disabled="submitButtonDisabled" + :loading="isCreatingRole" + :label="submitButtonLabel" + /> + </form> </template> diff --git a/app/assets/javascripts/create_cluster/eks_cluster/constants.js b/app/assets/javascripts/create_cluster/eks_cluster/constants.js index 339642f991e..a850ba89818 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/constants.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/constants.js @@ -1,7 +1,2 @@ // eslint-disable-next-line import/prefer-default-export -export const KUBERNETES_VERSIONS = [ - { name: '1.14', value: '1.14' }, - { name: '1.13', value: '1.13' }, - { name: '1.12', value: '1.12' }, - { name: '1.11', value: '1.11' }, -]; +export const KUBERNETES_VERSIONS = [{ name: '1.14', value: '1.14' }]; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/index.js b/app/assets/javascripts/create_cluster/eks_cluster/index.js index 1f595e9b2df..27f859d8972 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/index.js @@ -1,16 +1,54 @@ import Vue from 'vue'; import Vuex from 'vuex'; +import { parseBoolean } from '~/lib/utils/common_utils'; import CreateEksCluster from './components/create_eks_cluster.vue'; import createStore from './store'; Vue.use(Vuex); export default el => { - const { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath } = el.dataset; + const { + gitlabManagedClusterHelpPath, + kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + externalId, + accountId, + hasCredentials, + createRolePath, + createClusterPath, + signOutPath, + externalLinkIcon, + } = el.dataset; return new Vue({ el, - store: createStore(), + store: createStore({ + initialState: { + hasCredentials: parseBoolean(hasCredentials), + externalId, + accountId, + createRolePath, + createClusterPath, + signOutPath, + }, + apiPaths: { + getRolesPath, + getRegionsPath, + getKeyPairsPath, + getVpcsPath, + getSubnetsPath, + getSecurityGroupsPath, + getInstanceTypesPath, + }, + }), components: { CreateEksCluster, }, @@ -19,6 +57,9 @@ export default el => { props: { gitlabManagedClusterHelpPath, kubernetesIntegrationHelpPath, + accountAndExternalIdsHelpPath, + createRoleArnHelpPath, + externalLinkIcon, }, }); }, diff --git a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js index d982e4db4c1..21b87d525cf 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/services/aws_services_facade.js @@ -1,84 +1,58 @@ -import EC2 from 'aws-sdk/clients/ec2'; -import IAM from 'aws-sdk/clients/iam'; - -export const fetchRoles = () => { - const iam = new IAM(); - - return iam - .listRoles() - .promise() - .then(({ Roles: roles }) => roles.map(({ RoleName: name }) => ({ name }))); -}; - -export const fetchKeyPairs = () => { - const ec2 = new EC2(); - - return ec2 - .describeKeyPairs() - .promise() - .then(({ KeyPairs: keyPairs }) => keyPairs.map(({ RegionName: name }) => ({ name }))); -}; - -export const fetchRegions = () => { - const ec2 = new EC2(); - - return ec2 - .describeRegions() - .promise() - .then(({ Regions: regions }) => - regions.map(({ RegionName: name }) => ({ - name, - value: name, +import axios from '~/lib/utils/axios_utils'; + +export default apiPaths => ({ + fetchRoles() { + return axios + .get(apiPaths.getRolesPath) + .then(({ data: { roles } }) => + roles.map(({ role_name: name, arn: value }) => ({ name, value })), + ); + }, + fetchKeyPairs({ region }) { + return axios + .get(apiPaths.getKeyPairsPath, { params: { region } }) + .then(({ data: { key_pairs: keyPairs } }) => + keyPairs.map(({ key_name }) => ({ name: key_name, value: key_name })), + ); + }, + fetchRegions() { + return axios.get(apiPaths.getRegionsPath).then(({ data: { regions } }) => + regions.map(({ region_name }) => ({ + name: region_name, + value: region_name, })), ); -}; - -export const fetchVpcs = () => { - const ec2 = new EC2(); - - return ec2 - .describeVpcs() - .promise() - .then(({ Vpcs: vpcs }) => - vpcs.map(({ VpcId: id }) => ({ - value: id, - name: id, + }, + fetchVpcs({ region }) { + return axios.get(apiPaths.getVpcsPath, { params: { region } }).then(({ data: { vpcs } }) => + vpcs.map(({ vpc_id }) => ({ + value: vpc_id, + name: vpc_id, })), ); -}; - -export const fetchSubnets = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSubnets({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ Subnets: subnets }) => subnets.map(({ SubnetId: id }) => ({ id, name: id }))); -}; - -export const fetchSecurityGroups = ({ vpc }) => { - const ec2 = new EC2(); - - return ec2 - .describeSecurityGroups({ - Filters: [ - { - Name: 'vpc-id', - Values: [vpc], - }, - ], - }) - .promise() - .then(({ SecurityGroups: securityGroups }) => - securityGroups.map(({ GroupName: name, GroupId: value }) => ({ name, value })), - ); -}; - -export default () => {}; + }, + fetchSubnets({ vpc, region }) { + return axios + .get(apiPaths.getSubnetsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { subnets } }) => + subnets.map(({ subnet_id }) => ({ name: subnet_id, value: subnet_id })), + ); + }, + fetchSecurityGroups({ vpc, region }) { + return axios + .get(apiPaths.getSecurityGroupsPath, { params: { vpc_id: vpc, region } }) + .then(({ data: { security_groups: securityGroups } }) => + securityGroups.map(({ group_name: name, group_id: value }) => ({ name, value })), + ); + }, + fetchInstanceTypes() { + return axios + .get(apiPaths.getInstanceTypesPath) + .then(({ data: { instance_types: instanceTypes } }) => + instanceTypes.map(({ instance_type_name }) => ({ + name: instance_type_name, + value: instance_type_name, + })), + ); + }, +}); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index 917c8da6c3e..72f15263a8f 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,4 +1,12 @@ import * as types from './mutation_types'; +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; + +const getErrorMessage = data => { + const errorKey = Object.keys(data)[0]; + + return data[errorKey][0]; +}; export const setClusterName = ({ commit }, payload) => { commit(types.SET_CLUSTER_NAME, payload); @@ -12,6 +20,68 @@ export const setKubernetesVersion = ({ commit }, payload) => { commit(types.SET_KUBERNETES_VERSION, payload); }; +export const createRole = ({ dispatch, state: { createRolePath } }, payload) => { + dispatch('requestCreateRole'); + + return axios + .post(createRolePath, { + role_arn: payload.roleArn, + role_external_id: payload.externalId, + }) + .then(() => dispatch('createRoleSuccess')) + .catch(error => dispatch('createRoleError', { error })); +}; + +export const requestCreateRole = ({ commit }) => { + commit(types.REQUEST_CREATE_ROLE); +}; + +export const createRoleSuccess = ({ commit }) => { + commit(types.CREATE_ROLE_SUCCESS); +}; + +export const createRoleError = ({ commit }, payload) => { + commit(types.CREATE_ROLE_ERROR, payload); +}; + +export const createCluster = ({ dispatch, state }) => { + dispatch('requestCreateCluster'); + + return axios + .post(state.createClusterPath, { + name: state.clusterName, + environment_scope: state.environmentScope, + managed: state.gitlabManagedCluster, + provider_aws_attributes: { + region: state.selectedRegion, + vpc_id: state.selectedVpc, + subnet_ids: state.selectedSubnet, + role_arn: state.selectedRole, + key_name: state.selectedKeyPair, + security_group_id: state.selectedSecurityGroup, + instance_type: state.selectedInstanceType, + num_nodes: state.nodeCount, + }, + }) + .then(({ headers: { location } }) => dispatch('createClusterSuccess', location)) + .catch(({ response: { data } }) => { + dispatch('createClusterError', data); + }); +}; + +export const requestCreateCluster = ({ commit }) => { + commit(types.REQUEST_CREATE_CLUSTER); +}; + +export const createClusterSuccess = (_, location) => { + window.location.assign(location); +}; + +export const createClusterError = ({ commit }, error) => { + commit(types.CREATE_CLUSTER_ERROR, error); + createFlash(getErrorMessage(error)); +}; + export const setRegion = ({ commit }, payload) => { commit(types.SET_REGION, payload); }; @@ -40,4 +110,16 @@ export const setGitlabManagedCluster = ({ commit }, payload) => { commit(types.SET_GITLAB_MANAGED_CLUSTER, payload); }; -export default () => {}; +export const setInstanceType = ({ commit }, payload) => { + commit(types.SET_INSTANCE_TYPE, payload); +}; + +export const setNodeCount = ({ commit }, payload) => { + commit(types.SET_NODE_COUNT, payload); +}; + +export const signOut = ({ commit, state: { signOutPath } }) => + axios + .delete(signOutPath) + .then(() => commit(types.SIGN_OUT)) + .catch(({ response: { data } }) => createFlash(getErrorMessage(data))); diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js index d575deafd19..5982fc8a2fd 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/index.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/index.js @@ -6,14 +6,16 @@ import state from './state'; import clusterDropdownStore from './cluster_dropdown'; -import * as awsServices from '../services/aws_services_facade'; +import awsServicesFactory from '../services/aws_services_facade'; -const createStore = () => - new Vuex.Store({ +const createStore = ({ initialState, apiPaths }) => { + const awsServices = awsServicesFactory(apiPaths); + + return new Vuex.Store({ actions, getters, mutations, - state: state(), + state: Object.assign(state(), initialState), modules: { roles: { namespaced: true, @@ -39,7 +41,12 @@ const createStore = () => namespaced: true, ...clusterDropdownStore(awsServices.fetchSecurityGroups), }, + instanceTypes: { + namespaced: true, + ...clusterDropdownStore(awsServices.fetchInstanceTypes), + }, }, }); +}; export default createStore; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js index 82eb512ac07..f9204cc2207 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutation_types.js @@ -7,4 +7,13 @@ export const SET_KEY_PAIR = 'SET_KEY_PAIR'; export const SET_SUBNET = 'SET_SUBNET'; export const SET_ROLE = 'SET_ROLE'; export const SET_SECURITY_GROUP = 'SET_SECURITY_GROUP'; +export const SET_INSTANCE_TYPE = 'SET_INSTANCE_TYPE'; +export const SET_NODE_COUNT = 'SET_NODE_COUNT'; export const SET_GITLAB_MANAGED_CLUSTER = 'SET_GITLAB_MANAGED_CLUSTER'; +export const REQUEST_CREATE_ROLE = 'REQUEST_CREATE_ROLE'; +export const CREATE_ROLE_SUCCESS = 'CREATE_ROLE_SUCCESS'; +export const CREATE_ROLE_ERROR = 'CREATE_ROLE_ERROR'; +export const SIGN_OUT = 'SIGN_OUT'; +export const REQUEST_CREATE_CLUSTER = 'REQUEST_CREATE_CLUSTER'; +export const CREATE_CLUSTER_SUCCESS = 'CREATE_CLUSTER_SUCCESS'; +export const CREATE_CLUSTER_ERROR = 'CREATE_CLUSTER_ERROR'; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js index 79950ac7dce..aa04c8f7079 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/mutations.js @@ -28,7 +28,39 @@ export default { [types.SET_SECURITY_GROUP](state, { securityGroup }) { state.selectedSecurityGroup = securityGroup; }, + [types.SET_INSTANCE_TYPE](state, { instanceType }) { + state.selectedInstanceType = instanceType; + }, + [types.SET_NODE_COUNT](state, { nodeCount }) { + state.nodeCount = nodeCount; + }, [types.SET_GITLAB_MANAGED_CLUSTER](state, { gitlabManagedCluster }) { state.gitlabManagedCluster = gitlabManagedCluster; }, + [types.REQUEST_CREATE_ROLE](state) { + state.isCreatingRole = true; + state.createRoleError = null; + state.hasCredentials = false; + }, + [types.CREATE_ROLE_SUCCESS](state) { + state.isCreatingRole = false; + state.createRoleError = null; + state.hasCredentials = true; + }, + [types.CREATE_ROLE_ERROR](state, { error }) { + state.isCreatingRole = false; + state.createRoleError = error; + state.hasCredentials = false; + }, + [types.REQUEST_CREATE_CLUSTER](state) { + state.isCreatingCluster = true; + state.createClusterError = null; + }, + [types.CREATE_CLUSTER_ERROR](state, { error }) { + state.isCreatingCluster = false; + state.createClusterError = error; + }, + [types.SIGN_OUT](state) { + state.hasCredentials = false; + }, }; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js index bf74213bdce..2e3a05a9187 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/state.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/state.js @@ -1,18 +1,31 @@ import { KUBERNETES_VERSIONS } from '../constants'; +const [{ value: kubernetesVersion }] = KUBERNETES_VERSIONS; + export default () => ({ - isValidatingCredentials: false, - validCredentials: false, + createRolePath: null, + + isCreatingRole: false, + roleCreated: false, + createRoleError: false, + + accountId: '', + externalId: '', clusterName: '', environmentScope: '*', - kubernetesVersion: [KUBERNETES_VERSIONS].value, + kubernetesVersion, selectedRegion: '', selectedRole: '', selectedKeyPair: '', selectedVpc: '', selectedSubnet: '', selectedSecurityGroup: '', + selectedInstanceType: 'm5.large', + nodeCount: '3', + + isCreatingCluster: false, + createClusterError: false, gitlabManagedCluster: true, }); diff --git a/app/assets/javascripts/projects/gke_cluster_namespace/index.js b/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js index 0ec4d8807b0..0ec4d8807b0 100644 --- a/app/assets/javascripts/projects/gke_cluster_namespace/index.js +++ b/app/assets/javascripts/create_cluster/gke_cluster_namespace/index.js diff --git a/app/assets/javascripts/create_cluster/init_create_cluster.js b/app/assets/javascripts/create_cluster/init_create_cluster.js new file mode 100644 index 00000000000..7c984582fd8 --- /dev/null +++ b/app/assets/javascripts/create_cluster/init_create_cluster.js @@ -0,0 +1,37 @@ +import initGkeDropdowns from './gke_cluster'; +import initGkeNamespace from './gke_cluster_namespace'; +import PersistentUserCallout from '~/persistent_user_callout'; + +const newClusterViews = [':clusters:new', ':clusters:create_gcp', ':clusters:create_user']; + +const isProjectLevelCluster = page => page.startsWith('project:clusters'); + +export default (document, gon) => { + const { page } = document.body.dataset; + const isNewClusterView = newClusterViews.some(view => page.endsWith(view)); + + if (!isNewClusterView) { + return; + } + + const callout = document.querySelector('.gcp-signup-offer'); + PersistentUserCallout.factory(callout); + + initGkeDropdowns(); + + if (gon.features.createEksClusters) { + import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') + .then(({ default: initCreateEKSCluster }) => { + const el = document.querySelector('.js-create-eks-cluster-form-container'); + + if (el) { + initCreateEKSCluster(el); + } + }) + .catch(() => {}); + } + + if (isProjectLevelCluster(page)) { + initGkeNamespace(); + } +}; diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue deleted file mode 100644 index fc6d83bf96c..00000000000 --- a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script> -import Icon from '~/vue_shared/components/icon.vue'; -import { GlButton } from '@gitlab/ui'; - -export default { - name: 'StageCardListItem', - components: { - Icon, - GlButton, - }, - props: { - isActive: { - type: Boolean, - required: true, - }, - canEdit: { - type: Boolean, - default: false, - required: false, - }, - }, -}; -</script> - -<template> - <div - :class="{ active: isActive }" - class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" - > - <slot></slot> - <div v-if="canEdit" class="dropdown"> - <gl-button - :title="__('More actions')" - class="more-actions-toggle btn btn-transparent p-0" - data-toggle="dropdown" - > - <icon class="icon" name="ellipsis_v" /> - </gl-button> - <ul class="more-actions-dropdown dropdown-menu dropdown-open-left"> - <slot name="dropdown-options"></slot> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue index 004d335f572..1b09fe1b370 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -1,11 +1,6 @@ <script> -import StageCardListItem from './stage_card_list_item.vue'; - export default { name: 'StageNavItem', - components: { - StageCardListItem, - }, props: { isDefaultStage: { type: Boolean, @@ -40,16 +35,16 @@ export default { hasValue() { return this.value && this.value.length > 0; }, - editable() { - return this.isUserAllowed && this.canEdit; - }, }, }; </script> <template> <li @click="$emit('select')"> - <stage-card-list-item :is-active="isActive" :can-edit="editable"> + <div + :class="{ active: isActive }" + class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded border-color-default border-style-solid border-width-1px" + > <div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }"> {{ title }} </div> @@ -62,27 +57,6 @@ export default { <span class="not-available">{{ __('Not available') }}</span> </template> </div> - <template v-slot:dropdown-options> - <template v-if="isDefaultStage"> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Hide stage') }} - </button> - </li> - </template> - <template v-else> - <li> - <button type="button" class="btn-default btn-transparent"> - {{ __('Edit stage') }} - </button> - </li> - <li> - <button type="button" class="btn-danger danger"> - {{ __('Remove stage') }} - </button> - </li> - </template> - </template> - </stage-card-list-item> + </div> </li> </template> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 9a1e59ec045..a5ffa84e3fb 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -124,8 +124,10 @@ export default { :diff-viewer-mode="diffViewerMode" :new-path="diffFile.new_path" :new-sha="diffFile.diff_refs.head_sha" + :new-size="diffFile.new_size" :old-path="diffFile.old_path" :old-sha="diffFile.diff_refs.base_sha" + :old-size="diffFile.old_size" :file-hash="diffFileHash" :project-path="projectPath" :a-mode="diffFile.a_mode" diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 2514274224d..9236f0d5349 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -54,11 +54,12 @@ export default { showLoadingIcon() { return this.isLoadingCollapsedDiff || (!this.file.renderIt && !this.isCollapsed); }, - hasDiffLines() { + hasDiff() { return ( - this.file.highlighted_diff_lines && - this.file.parallel_diff_lines && - this.file.parallel_diff_lines.length > 0 + (this.file.highlighted_diff_lines && + this.file.parallel_diff_lines && + this.file.parallel_diff_lines.length > 0) || + !this.file.blob.readable_text ); }, isFileTooLarge() { @@ -82,7 +83,7 @@ export default { }, watch: { isCollapsed: function fileCollapsedWatch(newVal, oldVal) { - if (!newVal && oldVal && !this.hasDiffLines) { + if (!newVal && oldVal && !this.hasDiff) { this.handleLoadCollapsedDiff(); } @@ -103,7 +104,7 @@ export default { 'setFileCollapsed', ]), handleToggle() { - if (!this.hasDiffLines) { + if (!this.hasDiff) { this.handleLoadCollapsedDiff(); } else { this.isCollapsed = !this.isCollapsed; diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 0ff26445a6a..62b390a46d7 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -290,5 +290,5 @@ export default function dropzoneInput(form) { formTextarea.focus(); }); - return Dropzone.forElement($formDropzone.get(0)); + return $formDropzone.get(0) ? Dropzone.forElement($formDropzone.get(0)) : null; } diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue new file mode 100644 index 00000000000..37c9818f869 --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -0,0 +1,141 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import dateFormat from 'dateformat'; +import { __, sprintf } from '~/locale'; +import { GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import Stacktrace from './stacktrace.vue'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { trackClickErrorLinkToSentryOptions } from '../utils'; + +export default { + components: { + GlButton, + GlLink, + GlLoadingIcon, + TooltipOnTruncate, + Icon, + Stacktrace, + }, + directives: { + TrackEvent: TrackEventDirective, + }, + mixins: [timeagoMixin], + props: { + issueDetailsPath: { + type: String, + required: true, + }, + issueStackTracePath: { + type: String, + required: true, + }, + }, + computed: { + ...mapState('details', ['error', 'loading', 'loadingStacktrace', 'stacktraceData']), + ...mapGetters('details', ['stacktrace']), + reported() { + return sprintf( + __('Reported %{timeAgo} by %{reportedBy}'), + { + reportedBy: `<strong>${this.error.culprit}</strong>`, + timeAgo: this.timeFormated(this.stacktraceData.date_received), + }, + false, + ); + }, + firstReleaseLink() { + return `${this.error.external_base_url}/releases/${this.error.first_release_short_version}`; + }, + lastReleaseLink() { + return `${this.error.external_base_url}releases/${this.error.last_release_short_version}`; + }, + showDetails() { + return Boolean(!this.loading && this.error && this.error.id); + }, + showStacktrace() { + return Boolean(!this.loadingStacktrace && this.stacktrace && this.stacktrace.length); + }, + }, + mounted() { + this.startPollingDetails(this.issueDetailsPath); + this.startPollingStacktrace(this.issueStackTracePath); + }, + methods: { + ...mapActions('details', ['startPollingDetails', 'startPollingStacktrace']), + trackClickErrorLinkToSentryOptions, + formatDate(date) { + return `${this.timeFormated(date)} (${dateFormat(date, 'UTC:yyyy-mm-dd h:MM:ssTT Z')})`; + }, + }, +}; +</script> + +<template> + <div> + <div v-if="loading" class="py-3"> + <gl-loading-icon :size="3" /> + </div> + + <div v-else-if="showDetails" class="error-details"> + <div class="top-area align-items-center justify-content-between py-3"> + <span v-if="!loadingStacktrace && stacktrace" v-html="reported"></span> + <!-- <gl-button class="my-3 ml-auto" variant="success"> + {{ __('Create Issue') }} + </gl-button>--> + </div> + <div> + <tooltip-on-truncate :title="error.title" truncate-target="child" placement="top"> + <h2 class="text-truncate">{{ error.title }}</h2> + </tooltip-on-truncate> + <h3>{{ __('Error details') }}</h3> + <ul> + <li> + <span class="bold">{{ __('Sentry event') }}:</span> + <gl-link + v-track-event="trackClickErrorLinkToSentryOptions(error.external_url)" + :href="error.external_url" + target="_blank" + > + <span class="text-truncate">{{ error.external_url }}</span> + <icon name="external-link" class="ml-1 flex-shrink-0" /> + </gl-link> + </li> + <li v-if="error.first_release_short_version"> + <span class="bold">{{ __('First seen') }}:</span> + {{ formatDate(error.first_seen) }} + <gl-link :href="firstReleaseLink" target="_blank"> + <span>{{ __('Release') }}: {{ error.first_release_short_version }}</span> + </gl-link> + </li> + <li v-if="error.last_release_short_version"> + <span class="bold">{{ __('Last seen') }}:</span> + {{ formatDate(error.last_seen) }} + <gl-link :href="lastReleaseLink" target="_blank"> + <span>{{ __('Release') }}: {{ error.last_release_short_version }}</span> + </gl-link> + </li> + <li> + <span class="bold">{{ __('Events') }}:</span> + <span>{{ error.count }}</span> + </li> + <li> + <span class="bold">{{ __('Users') }}:</span> + <span>{{ error.user_count }}</span> + </li> + </ul> + + <div v-if="loadingStacktrace" class="py-3"> + <gl-loading-icon :size="3" /> + </div> + + <template v-if="showStacktrace"> + <h3 class="my-4">{{ __('Stack trace') }}</h3> + <stacktrace :entries="stacktrace" /> + </template> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index cd298e2c692..88139ce7403 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -1,11 +1,19 @@ <script> -import { mapActions, mapState } from 'vuex'; -import { GlEmptyState, GlButton, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { mapActions, mapState, mapGetters } from 'vuex'; +import { + GlEmptyState, + GlButton, + GlLink, + GlLoadingIcon, + GlTable, + GlSearchBoxByType, +} from '@gitlab/ui'; +import { visitUrl } from '~/lib/utils/url_utility'; import Icon from '~/vue_shared/components/icon.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { trackViewInSentryOptions, trackClickErrorLinkToSentryOptions } from '../utils'; +import { trackViewInSentryOptions } from '../utils'; export default { fields: [ @@ -20,6 +28,7 @@ export default { GlLink, GlLoadingIcon, GlTable, + GlSearchBoxByType, Icon, TimeAgo, }, @@ -48,8 +57,17 @@ export default { required: true, }, }, + data() { + return { + errorSearchQuery: '', + }; + }, computed: { - ...mapState(['errors', 'externalUrl', 'loading']), + ...mapState('list', ['errors', 'externalUrl', 'loading']), + ...mapGetters('list', ['filterErrorsByTitle']), + filteredErrors() { + return this.errorSearchQuery ? this.filterErrorsByTitle(this.errorSearchQuery) : this.errors; + }, }, created() { if (this.errorTrackingEnabled) { @@ -57,9 +75,11 @@ export default { } }, methods: { - ...mapActions(['startPolling', 'restartPolling']), + ...mapActions('list', ['startPolling', 'restartPolling']), trackViewInSentryOptions, - trackClickErrorLinkToSentryOptions, + viewDetails(errorId) { + visitUrl(`error_tracking/${errorId}/details`); + }, }, }; </script> @@ -71,10 +91,17 @@ export default { <gl-loading-icon :size="3" /> </div> <div v-else> - <div class="d-flex justify-content-end"> + <div class="d-flex flex-row justify-content-around bg-secondary border"> + <gl-search-box-by-type + v-model="errorSearchQuery" + class="col-lg-10 m-3 p-0" + :placeholder="__('Search or filter results...')" + type="search" + autofocus + /> <gl-button v-track-event="trackViewInSentryOptions(externalUrl)" - class="my-3 ml-auto" + class="m-3" variant="primary" :href="externalUrl" target="_blank" @@ -84,7 +111,14 @@ export default { </gl-button> </div> - <gl-table :items="errors" :fields="$options.fields" :show-empty="true" fixed stacked="sm"> + <gl-table + class="mt-3" + :items="filteredErrors" + :fields="$options.fields" + :show-empty="true" + fixed + stacked="sm" + > <template slot="HEAD_events" slot-scope="data"> <div class="text-md-right">{{ data.label }}</div> </template> @@ -94,13 +128,11 @@ export default { <template slot="error" slot-scope="errors"> <div class="d-flex flex-column"> <gl-link - v-track-event="trackClickErrorLinkToSentryOptions(errors.item.externalUrl)" - :href="errors.item.externalUrl" class="d-flex text-dark" target="_blank" + @click="viewDetails(errors.item.id)" > <strong class="text-truncate">{{ errors.item.title.trim() }}</strong> - <icon name="external-link" class="ml-1 flex-shrink-0" /> </gl-link> <span class="text-secondary text-truncate"> {{ errors.item.culprit }} diff --git a/app/assets/javascripts/error_tracking/components/stacktrace.vue b/app/assets/javascripts/error_tracking/components/stacktrace.vue new file mode 100644 index 00000000000..6b71967624f --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/stacktrace.vue @@ -0,0 +1,33 @@ +<script> +import StackTraceEntry from './stacktrace_entry.vue'; + +export default { + components: { + StackTraceEntry, + }, + props: { + entries: { + type: Array, + required: true, + }, + }, + methods: { + isFirstEntry(index) { + return index === 0; + }, + }, +}; +</script> + +<template> + <div class="stacktrace"> + <stack-trace-entry + v-for="(entry, index) in entries" + :key="`stacktrace-entry-${index}`" + :lines="entry.context" + :file-path="entry.filename" + :error-line="entry.lineNo" + :expanded="isFirstEntry(index)" + /> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue new file mode 100644 index 00000000000..ad542c579a9 --- /dev/null +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -0,0 +1,110 @@ +<script> +import { GlTooltip } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + components: { + ClipboardButton, + FileIcon, + Icon, + }, + directives: { + GlTooltip, + }, + props: { + lines: { + type: Array, + required: true, + }, + filePath: { + type: String, + required: true, + }, + errorLine: { + type: Number, + required: true, + }, + expanded: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isExpanded: this.expanded, + }; + }, + computed: { + linesLength() { + return this.lines.length; + }, + collapseIcon() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + }, + methods: { + isHighlighted(lineNum) { + return lineNum === this.errorLine; + }, + toggle() { + this.isExpanded = !this.isExpanded; + }, + lineNum(line) { + return line[0]; + }, + lineCode(line) { + return line[1]; + }, + }, + userColorScheme: window.gon.user_color_scheme, +}; +</script> + +<template> + <div class="file-holder"> + <div ref="header" class="file-title file-title-flex-parent"> + <div class="file-header-content "> + <div class="d-inline-block cursor-pointer" @click="toggle()"> + <icon :name="collapseIcon" :size="16" aria-hidden="true" class="append-right-5" /> + </div> + <div class="d-inline-block append-right-4"> + <file-icon + :file-name="filePath" + :size="18" + aria-hidden="true" + css-classes="append-right-5" + /> + <strong v-gl-tooltip :title="filePath" class="file-title-name" data-container="body"> + {{ filePath }} + </strong> + </div> + + <clipboard-button + :title="__('Copy file path')" + :text="filePath" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + </div> + + <table v-if="isExpanded" :class="$options.userColorScheme" class="code js-syntax-highlight"> + <tbody> + <template v-for="(line, index) in lines"> + <tr :key="`stacktrace-line-${index}`" class="line_holder"> + <td class="diff-line-num" :class="{ old: isHighlighted(lineNum(line)) }"> + {{ lineNum(line) }} + </td> + <td + class="line_content" + :class="{ old: isHighlighted(lineNum(line)) }" + v-html="lineCode(line)" + ></td> + </tr> + </template> + </tbody> + </table> + </div> +</template> diff --git a/app/assets/javascripts/error_tracking/details.js b/app/assets/javascripts/error_tracking/details.js new file mode 100644 index 00000000000..b9b51a6539f --- /dev/null +++ b/app/assets/javascripts/error_tracking/details.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import store from './store'; +import ErrorDetails from './components/error_details.vue'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: '#js-error_details', + components: { + ErrorDetails, + }, + store, + render(createElement) { + const domEl = document.querySelector(this.$options.el); + const { issueDetailsPath, issueStackTracePath } = domEl.dataset; + + return createElement('error-details', { + props: { + issueDetailsPath, + issueStackTracePath, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/error_tracking/index.js b/app/assets/javascripts/error_tracking/list.js index 073e2c8f1c7..073e2c8f1c7 100644 --- a/app/assets/javascripts/error_tracking/index.js +++ b/app/assets/javascripts/error_tracking/list.js diff --git a/app/assets/javascripts/error_tracking/services/index.js b/app/assets/javascripts/error_tracking/services/index.js index ab89521dc46..68988296cc2 100644 --- a/app/assets/javascripts/error_tracking/services/index.js +++ b/app/assets/javascripts/error_tracking/services/index.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; export default { - getErrorList({ endpoint }) { + getSentryData({ endpoint }) { return axios.get(endpoint); }, }; diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js new file mode 100644 index 00000000000..0390bca7175 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -0,0 +1,63 @@ +import service from '../../services'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import Poll from '~/lib/utils/poll'; +import { __ } from '~/locale'; + +let stackTracePoll; +let detailPoll; + +const stopPolling = poll => { + if (poll) poll.stop(); +}; + +export function startPollingDetails({ commit }, endpoint) { + detailPoll = new Poll({ + resource: service, + method: 'getSentryData', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + detailPoll.restart(); + return; + } + + commit(types.SET_ERROR, data.error); + commit(types.SET_LOADING, false); + + stopPolling(detailPoll); + }, + errorCallback: () => { + commit(types.SET_LOADING, false); + createFlash(__('Failed to load error details from Sentry.')); + }, + }); + + detailPoll.makeRequest(); +} + +export function startPollingStacktrace({ commit }, endpoint) { + stackTracePoll = new Poll({ + resource: service, + method: 'getSentryData', + data: { endpoint }, + successCallback: ({ data }) => { + if (!data) { + stackTracePoll.restart(); + return; + } + commit(types.SET_STACKTRACE_DATA, data.error); + commit(types.SET_LOADING_STACKTRACE, false); + + stopPolling(stackTracePoll); + }, + errorCallback: () => { + commit(types.SET_LOADING_STACKTRACE, false); + createFlash(__('Failed to load stacktrace.')); + }, + }); + + stackTracePoll.makeRequest(); +} + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/getters.js b/app/assets/javascripts/error_tracking/store/details/getters.js new file mode 100644 index 00000000000..7d13439d721 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/getters.js @@ -0,0 +1,3 @@ +export const stacktrace = state => state.stacktraceData.stack_trace_entries.reverse(); + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/mutation_types.js b/app/assets/javascripts/error_tracking/store/details/mutation_types.js new file mode 100644 index 00000000000..a2592253a2d --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_ERROR = 'SET_ERRORS'; +export const SET_LOADING = 'SET_LOADING'; +export const SET_LOADING_STACKTRACE = 'SET_LOADING_STACKTRACE'; +export const SET_STACKTRACE_DATA = 'SET_STACKTRACE_DATA'; diff --git a/app/assets/javascripts/error_tracking/store/details/mutations.js b/app/assets/javascripts/error_tracking/store/details/mutations.js new file mode 100644 index 00000000000..6f4720444e0 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/mutations.js @@ -0,0 +1,16 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ERROR](state, data) { + state.error = data; + }, + [types.SET_LOADING](state, loading) { + state.loading = loading; + }, + [types.SET_LOADING_STACKTRACE](state, data) { + state.loadingStacktrace = data; + }, + [types.SET_STACKTRACE_DATA](state, data) { + state.stacktraceData = data; + }, +}; diff --git a/app/assets/javascripts/error_tracking/store/details/state.js b/app/assets/javascripts/error_tracking/store/details/state.js new file mode 100644 index 00000000000..95fb0ba0558 --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/details/state.js @@ -0,0 +1,6 @@ +export default () => ({ + error: {}, + stacktraceData: {}, + loading: true, + loadingStacktrace: true, +}); diff --git a/app/assets/javascripts/error_tracking/store/index.js b/app/assets/javascripts/error_tracking/store/index.js index 3136682fb64..941c752e96a 100644 --- a/app/assets/javascripts/error_tracking/store/index.js +++ b/app/assets/javascripts/error_tracking/store/index.js @@ -1,19 +1,36 @@ import Vue from 'vue'; import Vuex from 'vuex'; -import * as actions from './actions'; -import mutations from './mutations'; + +import * as listActions from './list/actions'; +import listMutations from './list/mutations'; +import listState from './list/state'; +import * as listGetters from './list/getters'; + +import * as detailsActions from './details/actions'; +import detailsMutations from './details/mutations'; +import detailsState from './details/state'; +import * as detailsGetters from './details/getters'; Vue.use(Vuex); export const createStore = () => new Vuex.Store({ - state: { - errors: [], - externalUrl: '', - loading: true, + modules: { + list: { + namespaced: true, + state: listState(), + actions: listActions, + mutations: listMutations, + getters: listGetters, + }, + details: { + namespaced: true, + state: detailsState(), + actions: detailsActions, + mutations: detailsMutations, + getters: detailsGetters, + }, }, - actions, - mutations, }); export default createStore(); diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index 1e754a4f54f..18c6e5e9695 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -1,4 +1,4 @@ -import Service from '../services'; +import Service from '../../services'; import * as types from './mutation_types'; import createFlash from '~/flash'; import Poll from '~/lib/utils/poll'; @@ -9,7 +9,7 @@ let eTagPoll; export function startPolling({ commit, dispatch }, endpoint) { eTagPoll = new Poll({ resource: Service, - method: 'getErrorList', + method: 'getSentryData', data: { endpoint }, successCallback: ({ data }) => { if (!data) { diff --git a/app/assets/javascripts/error_tracking/store/list/getters.js b/app/assets/javascripts/error_tracking/store/list/getters.js new file mode 100644 index 00000000000..1a2ec62f79f --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/list/getters.js @@ -0,0 +1,4 @@ +export const filterErrorsByTitle = state => errorQuery => + state.errors.filter(error => error.title.match(new RegExp(`${errorQuery}`, 'i'))); + +export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/mutation_types.js b/app/assets/javascripts/error_tracking/store/list/mutation_types.js index f9d77a6b08e..f9d77a6b08e 100644 --- a/app/assets/javascripts/error_tracking/store/mutation_types.js +++ b/app/assets/javascripts/error_tracking/store/list/mutation_types.js diff --git a/app/assets/javascripts/error_tracking/store/mutations.js b/app/assets/javascripts/error_tracking/store/list/mutations.js index e4bd81db9c9..e4bd81db9c9 100644 --- a/app/assets/javascripts/error_tracking/store/mutations.js +++ b/app/assets/javascripts/error_tracking/store/list/mutations.js diff --git a/app/assets/javascripts/error_tracking/store/list/state.js b/app/assets/javascripts/error_tracking/store/list/state.js new file mode 100644 index 00000000000..d371350ef0e --- /dev/null +++ b/app/assets/javascripts/error_tracking/store/list/state.js @@ -0,0 +1,5 @@ +export default () => ({ + errors: [], + externalUrl: '', + loading: true, +}); diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 50eb3e63b7c..786abc8ce49 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -43,16 +43,7 @@ export default { 'isProjectInvalid', 'projectSelectionLabel', ]), - ...mapState([ - 'apiHost', - 'connectError', - 'connectSuccessful', - 'enabled', - 'projects', - 'selectedProject', - 'settingsLoading', - 'token', - ]), + ...mapState(['enabled', 'projects', 'selectedProject', 'settingsLoading', 'token']), }, created() { this.setInitialState({ @@ -65,15 +56,7 @@ export default { }); }, methods: { - ...mapActions([ - 'fetchProjects', - 'setInitialState', - 'updateApiHost', - 'updateEnabled', - 'updateSelectedProject', - 'updateSettings', - 'updateToken', - ]), + ...mapActions(['setInitialState', 'updateEnabled', 'updateSelectedProject', 'updateSettings']), handleSubmit() { this.updateSettings(); }, @@ -95,15 +78,7 @@ export default { s__('ErrorTracking|Active') }}</label> </div> - <error-tracking-form - :api-host="apiHost" - :connect-error="connectError" - :connect-successful="connectSuccessful" - :token="token" - @handle-connect="fetchProjects" - @update-api-host="updateApiHost" - @update-token="updateToken" - /> + <error-tracking-form /> <div class="form-group"> <project-dropdown :has-projects="hasProjects" diff --git a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue index a734e8527dd..d86116aa315 100644 --- a/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue +++ b/app/assets/javascripts/error_tracking_settings/components/error_tracking_form.vue @@ -1,32 +1,20 @@ <script> -import { GlButton, GlFormInput } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { GlFormInput } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; export default { - components: { GlButton, GlFormInput, Icon }, - props: { - apiHost: { - type: String, - required: true, - }, - connectError: { - type: Boolean, - required: true, - }, - connectSuccessful: { - type: Boolean, - required: true, - }, - token: { - type: String, - required: true, - }, - }, + components: { GlFormInput, Icon, LoadingButton }, computed: { + ...mapState(['apiHost', 'connectError', 'connectSuccessful', 'isLoadingProjects', 'token']), tokenInputState() { return this.connectError ? false : null; }, }, + methods: { + ...mapActions(['fetchProjects', 'updateApiHost', 'updateToken']), + }, }; </script> @@ -40,8 +28,9 @@ export default { <gl-form-input id="error-tracking-api-host" :value="apiHost" + :disabled="isLoadingProjects" placeholder="https://mysentryserver.com" - @input="$emit('update-api-host', $event)" + @input="updateApiHost" /> <!-- eslint-enable @gitlab/vue-i18n/no-bare-attribute-strings --> </div> @@ -60,15 +49,17 @@ export default { id="error-tracking-token" :value="token" :state="tokenInputState" - @input="$emit('update-token', $event)" + :disabled="isLoadingProjects" + @input="updateToken" /> </div> <div class="col-4 col-md-3 gl-pl-0"> - <gl-button - class="js-error-tracking-connect prepend-left-5" - @click="$emit('handle-connect')" - >{{ __('Connect') }}</gl-button - > + <loading-button + class="js-error-tracking-connect prepend-left-5 d-inline-flex" + :label="isLoadingProjects ? __('Connecting') : __('Connect')" + :loading="isLoadingProjects" + @click="fetchProjects" + /> <icon v-show="connectSuccessful" class="js-error-tracking-connect-success prepend-left-5 text-success align-middle" diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 95105797807..6b540ea7dfd 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -6,17 +6,20 @@ import { transformFrontendSettings } from '../utils'; import * as types from './mutation_types'; export const requestProjects = ({ commit }) => { + commit(types.SET_PROJECTS_LOADING, true); commit(types.RESET_CONNECT); }; export const receiveProjectsSuccess = ({ commit }, projects) => { commit(types.UPDATE_CONNECT_SUCCESS); commit(types.RECEIVE_PROJECTS, projects); + commit(types.SET_PROJECTS_LOADING, false); }; export const receiveProjectsError = ({ commit }) => { commit(types.UPDATE_CONNECT_ERROR); commit(types.CLEAR_PROJECTS); + commit(types.SET_PROJECTS_LOADING, false); }; export const fetchProjects = ({ dispatch, state }) => { diff --git a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js index b4f8a237947..bf3df383ddc 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutation_types.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutation_types.js @@ -9,3 +9,4 @@ export const UPDATE_ENABLED = 'UPDATE_ENABLED'; export const UPDATE_SELECTED_PROJECT = 'UPDATE_SELECTED_PROJECT'; export const UPDATE_SETTINGS_LOADING = 'UPDATE_SETTINGS_LOADING'; export const UPDATE_TOKEN = 'UPDATE_TOKEN'; +export const SET_PROJECTS_LOADING = 'SET_PROJECTS_LOADING'; diff --git a/app/assets/javascripts/error_tracking_settings/store/mutations.js b/app/assets/javascripts/error_tracking_settings/store/mutations.js index 4089d1ee94e..133f25264b9 100644 --- a/app/assets/javascripts/error_tracking_settings/store/mutations.js +++ b/app/assets/javascripts/error_tracking_settings/store/mutations.js @@ -58,4 +58,7 @@ export default { state.connectSuccessful = false; state.connectError = true; }, + [types.SET_PROJECTS_LOADING](state, loading) { + state.isLoadingProjects = loading; + }, }; diff --git a/app/assets/javascripts/error_tracking_settings/store/state.js b/app/assets/javascripts/error_tracking_settings/store/state.js index 98219d33f4d..ab616f11e83 100644 --- a/app/assets/javascripts/error_tracking_settings/store/state.js +++ b/app/assets/javascripts/error_tracking_settings/store/state.js @@ -3,6 +3,7 @@ export default () => ({ enabled: false, token: '', projects: [], + isLoadingProjects: false, selectedProject: null, settingsLoading: false, connectSuccessful: false, diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index f280f3cd26c..5fa07045d5e 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -13,6 +13,7 @@ export default class AvailableDropdownMappings { runnerTagsEndpoint, labelsEndpoint, milestonesEndpoint, + releasesEndpoint, groupsOnly, includeAncestorGroups, includeDescendantGroups, @@ -21,6 +22,7 @@ export default class AvailableDropdownMappings { this.runnerTagsEndpoint = runnerTagsEndpoint; this.labelsEndpoint = labelsEndpoint; this.milestonesEndpoint = milestonesEndpoint; + this.releasesEndpoint = releasesEndpoint; this.groupsOnly = groupsOnly; this.includeAncestorGroups = includeAncestorGroups; this.includeDescendantGroups = includeDescendantGroups; @@ -70,6 +72,19 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-milestone'), }, + release: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getReleasesEndpoint(), + symbol: '', + + // The DropdownNonUser class is hardcoded to look for and display a + // "title" property, so we need to add this property to each release object + preprocessing: releases => releases.map(r => ({ ...r, title: r.tag })), + }, + element: this.container.querySelector('#js-dropdown-release'), + }, label: { reference: null, gl: DropdownNonUser, @@ -130,6 +145,10 @@ export default class AvailableDropdownMappings { return `${this.milestonesEndpoint}.json`; } + getReleasesEndpoint() { + return `${this.releasesEndpoint}.json`; + } + getLabelsEndpoint() { let endpoint = `${this.labelsEndpoint}.json?`; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index 835d3bf8a53..5ff95f45be4 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -11,6 +11,7 @@ export default class FilteredSearchDropdownManager { runnerTagsEndpoint = '', labelsEndpoint = '', milestonesEndpoint = '', + releasesEndpoint = '', tokenizer, page, isGroup, @@ -18,10 +19,13 @@ export default class FilteredSearchDropdownManager { isGroupDecendent, filteredSearchTokenKeys, }) { + const removeTrailingSlash = url => url.replace(/\/$/, ''); + this.container = FilteredSearchContainer.container; - this.runnerTagsEndpoint = runnerTagsEndpoint.replace(/\/$/, ''); - this.labelsEndpoint = labelsEndpoint.replace(/\/$/, ''); - this.milestonesEndpoint = milestonesEndpoint.replace(/\/$/, ''); + this.runnerTagsEndpoint = removeTrailingSlash(runnerTagsEndpoint); + this.labelsEndpoint = removeTrailingSlash(labelsEndpoint); + this.milestonesEndpoint = removeTrailingSlash(milestonesEndpoint); + this.releasesEndpoint = removeTrailingSlash(releasesEndpoint); this.tokenizer = tokenizer; this.filteredSearchTokenKeys = filteredSearchTokenKeys || FilteredSearchTokenKeys; this.filteredSearchInput = this.container.querySelector('.filtered-search'); @@ -54,6 +58,7 @@ export default class FilteredSearchDropdownManager { this.runnerTagsEndpoint, this.labelsEndpoint, this.milestonesEndpoint, + this.releasesEndpoint, this.groupsOnly, this.includeAncestorGroups, this.includeDescendantGroups, diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index fd335362e5b..5c2d32f4e85 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -89,6 +89,7 @@ export default class FilteredSearchManager { this.filteredSearchInput.getAttribute('data-runner-tags-endpoint') || '', labelsEndpoint: this.filteredSearchInput.getAttribute('data-labels-endpoint') || '', milestonesEndpoint: this.filteredSearchInput.getAttribute('data-milestones-endpoint') || '', + releasesEndpoint: this.filteredSearchInput.getAttribute('data-releases-endpoint') || '', tokenizer: this.tokenizer, page: this.page, isGroup: this.isGroup, diff --git a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js index 6c3d9e33420..414bcf186a3 100644 --- a/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/issuable_filtered_search_token_keys.js @@ -1,7 +1,9 @@ import FilteredSearchTokenKeys from './filtered_search_token_keys'; import { __ } from '~/locale'; -export const tokenKeys = [ +export const tokenKeys = []; + +tokenKeys.push( { key: 'author', type: 'string', @@ -26,15 +28,27 @@ export const tokenKeys = [ icon: 'clock', tag: '%milestone', }, - { - key: 'label', - type: 'array', - param: 'name[]', - symbol: '~', - icon: 'labels', - tag: '~label', - }, -]; +); + +if (gon && gon.features && gon.features.releaseSearchFilter) { + tokenKeys.push({ + key: 'release', + type: 'string', + param: 'tag', + symbol: '', + icon: 'rocket', + tag: __('tag name'), + }); +} + +tokenKeys.push({ + key: 'label', + type: 'array', + param: 'name[]', + symbol: '~', + icon: 'labels', + tag: '~label', +}); if (gon.current_user_id) { // Appending tokenkeys only logged-in @@ -89,6 +103,16 @@ export const conditions = [ value: __('Started'), }, { + url: 'release_tag=None', + tokenKey: 'release', + value: __('None'), + }, + { + url: 'release_tag=Any', + tokenKey: 'release', + value: __('Any'), + }, + { url: 'label_name[]=None', tokenKey: 'label', value: __('None'), diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index fc9c5827ed4..2c3320b5e79 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -37,7 +37,7 @@ const createAction = config => ` `; const createFlashEl = (message, type) => ` - <div class="flash-content flash-${type} rounded"> + <div class="flash-${type}"> <div class="flash-text"> ${_.escape(message)} <div class="close-icon-wrapper js-close-icon"> diff --git a/app/assets/javascripts/frequent_items/store/mutations.js b/app/assets/javascripts/frequent_items/store/mutations.js index 41b660a243f..92ac3a2c94d 100644 --- a/app/assets/javascripts/frequent_items/store/mutations.js +++ b/app/assets/javascripts/frequent_items/store/mutations.js @@ -47,7 +47,8 @@ export default { hasSearchQuery: true, }); }, - [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, rawItems) { + [types.RECEIVE_SEARCHED_ITEMS_SUCCESS](state, results) { + const rawItems = results.data; Object.assign(state, { items: rawItems.map(rawItem => ({ id: rawItem.id, diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 4e1b4f2652c..045f77af7ea 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -617,7 +617,7 @@ GitLabDropdown = (function() { GitLabDropdown.prototype.hidden = function(e) { var $input; this.resetRows(); - this.removeArrayKeyEvent(); + this.removeArrowKeyEvent(); $input = this.dropdown.find('.dropdown-input-field'); if (this.options.filterable) { $input.blur(); @@ -900,7 +900,7 @@ GitLabDropdown = (function() { ); }; - GitLabDropdown.prototype.removeArrayKeyEvent = function() { + GitLabDropdown.prototype.removeArrowKeyEvent = function() { return $('body').off('keydown'); }; diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue new file mode 100644 index 00000000000..bd504d95ee2 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -0,0 +1,103 @@ +<script> +import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import { mapState, mapActions } from 'vuex'; + +export default { + components: { + GlButton, + GlFormCheckbox, + GlFormGroup, + GlFormInput, + GlLink, + Icon, + }, + data() { + return { placeholderUrl: 'https://my-url.grafana.net/' }; + }, + computed: { + ...mapState(['operationsSettingsEndpoint', 'grafanaToken', 'grafanaUrl', 'grafanaEnabled']), + integrationEnabled: { + get() { + return this.grafanaEnabled; + }, + set(grafanaEnabled) { + this.setGrafanaEnabled(grafanaEnabled); + }, + }, + localGrafanaToken: { + get() { + return this.grafanaToken; + }, + set(token) { + this.setGrafanaToken(token); + }, + }, + localGrafanaUrl: { + get() { + return this.grafanaUrl; + }, + set(url) { + this.setGrafanaUrl(url); + }, + }, + }, + methods: { + ...mapActions([ + 'setGrafanaUrl', + 'setGrafanaToken', + 'setGrafanaEnabled', + 'updateGrafanaIntegration', + ]), + }, +}; +</script> + +<template> + <section id="grafana" class="settings no-animate js-grafana-integration"> + <div class="settings-header"> + <h4 class="js-section-header"> + {{ s__('GrafanaIntegration|Grafana Authentication') }} + </h4> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> + <p class="js-section-sub-header"> + {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }} + </p> + </div> + <div class="settings-content"> + <form> + <gl-form-checkbox + id="grafana-integration-enabled" + v-model="integrationEnabled" + class="mb-4" + > + {{ s__('GrafanaIntegration|Active') }} + </gl-form-checkbox> + <gl-form-group + :label="s__('GrafanaIntegration|Grafana URL')" + label-for="grafana-url" + :description="s__('GrafanaIntegration|Enter the base URL of the Grafana instance.')" + > + <gl-form-input id="grafana-url" v-model="localGrafanaUrl" :placeholder="placeholderUrl" /> + </gl-form-group> + <gl-form-group :label="s__('GrafanaIntegration|API Token')" label-for="grafana-token"> + <gl-form-input id="grafana-token" v-model="localGrafanaToken" /> + <p class="form-text text-muted"> + {{ s__('GrafanaIntegration|Enter the Grafana API Token.') }} + <a + href="https://grafana.com/docs/http_api/auth/#create-api-token" + target="_blank" + rel="noopener noreferrer" + > + {{ __('More information') }} + <icon name="external-link" class="vertical-align-middle" /> + </a> + </p> + </gl-form-group> + <gl-button variant="success" @click="updateGrafanaIntegration"> + {{ __('Save Changes') }} + </gl-button> + </form> + </div> + </section> +</template> diff --git a/app/assets/javascripts/grafana_integration/index.js b/app/assets/javascripts/grafana_integration/index.js new file mode 100644 index 00000000000..a93edab4388 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import store from './store'; +import GrafanaIntegration from './components/grafana_integration.vue'; + +export default () => { + const el = document.querySelector('.js-grafana-integration'); + return new Vue({ + el, + store: store(el.dataset), + render(createElement) { + return createElement(GrafanaIntegration); + }, + }); +}; diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js new file mode 100644 index 00000000000..d83f1e0831c --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -0,0 +1,42 @@ +import axios from '~/lib/utils/axios_utils'; +import { __ } from '~/locale'; +import createFlash from '~/flash'; +import { refreshCurrentPage } from '~/lib/utils/url_utility'; +import * as mutationTypes from './mutation_types'; + +export const setGrafanaUrl = ({ commit }, url) => commit(mutationTypes.SET_GRAFANA_URL, url); + +export const setGrafanaToken = ({ commit }, token) => + commit(mutationTypes.SET_GRAFANA_TOKEN, token); + +export const setGrafanaEnabled = ({ commit }, enabled) => + commit(mutationTypes.SET_GRAFANA_ENABLED, enabled); + +export const updateGrafanaIntegration = ({ state, dispatch }) => + axios + .patch(state.operationsSettingsEndpoint, { + project: { + grafana_integration_attributes: { + grafana_url: state.grafanaUrl, + token: state.grafanaToken, + enabled: state.grafanaEnabled, + }, + }, + }) + .then(() => dispatch('receiveGrafanaIntegrationUpdateSuccess')) + .catch(error => dispatch('receiveGrafanaIntegrationUpdateError', error)); + +export const receiveGrafanaIntegrationUpdateSuccess = () => { + /** + * The operations_controller currently handles successful requests + * by creating a flash banner messsage to notify the user. + */ + refreshCurrentPage(); +}; + +export const receiveGrafanaIntegrationUpdateError = (_, error) => { + const { response } = error; + const message = response.data && response.data.message ? response.data.message : ''; + + createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); +}; diff --git a/app/assets/javascripts/grafana_integration/store/index.js b/app/assets/javascripts/grafana_integration/store/index.js new file mode 100644 index 00000000000..e96bb1e8aad --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import createState from './state'; +import * as actions from './actions'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export const createStore = initialState => + new Vuex.Store({ + state: createState(initialState), + actions, + mutations, + }); + +export default createStore; diff --git a/app/assets/javascripts/grafana_integration/store/mutation_types.js b/app/assets/javascripts/grafana_integration/store/mutation_types.js new file mode 100644 index 00000000000..314c3a4039a --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/mutation_types.js @@ -0,0 +1,3 @@ +export const SET_GRAFANA_URL = 'SET_GRAFANA_URL'; +export const SET_GRAFANA_TOKEN = 'SET_GRAFANA_TOKEN'; +export const SET_GRAFANA_ENABLED = 'SET_GRAFANA_ENABLED'; diff --git a/app/assets/javascripts/grafana_integration/store/mutations.js b/app/assets/javascripts/grafana_integration/store/mutations.js new file mode 100644 index 00000000000..0992030d404 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/mutations.js @@ -0,0 +1,13 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_GRAFANA_URL](state, url) { + state.grafanaUrl = url; + }, + [types.SET_GRAFANA_TOKEN](state, token) { + state.grafanaToken = token; + }, + [types.SET_GRAFANA_ENABLED](state, enabled) { + state.grafanaEnabled = enabled; + }, +}; diff --git a/app/assets/javascripts/grafana_integration/store/state.js b/app/assets/javascripts/grafana_integration/store/state.js new file mode 100644 index 00000000000..a912eb58327 --- /dev/null +++ b/app/assets/javascripts/grafana_integration/store/state.js @@ -0,0 +1,8 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + +export default (initialState = {}) => ({ + operationsSettingsEndpoint: initialState.operationsSettingsEndpoint, + grafanaToken: initialState.grafanaIntegrationToken || '', + grafanaUrl: initialState.grafanaIntegrationUrl || '', + grafanaEnabled: parseBoolean(initialState.grafanaIntegrationEnabled) || false, +}); diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index 460174caf4d..eda0f5d1d23 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,15 +1,23 @@ import $ from 'jquery'; import { slugify } from './lib/utils/text_utility'; +import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; +import flash from '~/flash'; +import { __ } from '~/locale'; export default class Group { constructor() { this.groupPath = $('#group_path'); this.groupName = $('#group_name'); + this.parentId = $('#group_parent_id'); this.updateHandler = this.update.bind(this); this.resetHandler = this.reset.bind(this); + this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this); if (this.groupName.val() === '') { this.groupName.on('keyup', this.updateHandler); this.groupPath.on('keydown', this.resetHandler); + if (!this.parentId.val()) { + this.groupName.on('blur', this.updateGroupPathSlugHandler); + } } } @@ -21,5 +29,21 @@ export default class Group { reset() { this.groupName.off('keyup', this.updateHandler); this.groupPath.off('keydown', this.resetHandler); + this.groupName.off('blur', this.checkPathHandler); + } + + updateGroupPathSlug() { + const slug = this.groupPath.val() || slugify(this.groupName.val()); + if (!slug) return; + + fetchGroupPathAvailability(slug) + .then(({ data }) => data) + .then(data => { + if (data.exists && data.suggests.length > 0) { + const suggestedSlug = data.suggests[0]; + this.groupPath.val(suggestedSlug); + } + }) + .catch(() => flash(__('An error occurred while checking group path'))); } } diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index 2c2a04d5b5e..d172aa8a444 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -1,17 +1,31 @@ -/* eslint-disable import/prefer-default-export */ - +/** + * @param {Array} queryResults - Array of Result objects + * @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name) + * @returns {Array} The formatted values + */ +// eslint-disable-next-line import/prefer-default-export export const makeDataSeries = (queryResults, defaultConfig) => - queryResults.reduce((acc, result) => { - const data = result.values.filter(([, value]) => !Number.isNaN(value)); - if (!data.length) { - return acc; - } - const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); - const name = result.metric[relevantMetric]; - const series = { data }; - if (name) { - series.name = `${defaultConfig.name}: ${name}`; - } + queryResults + .map(result => { + const data = result.values.filter(([, value]) => !Number.isNaN(value)); + if (!data.length) { + return null; + } + const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); + const name = result.metric[relevantMetric]; + const series = { data }; + if (name) { + series.name = `${defaultConfig.name}: ${name}`; + } else { + series.name = defaultConfig.name; + Object.keys(result.metric).forEach(templateVar => { + const value = result.metric[templateVar]; + const regex = new RegExp(`{{\\s*${templateVar}\\s*}}`, 'g'); + + series.name = series.name.replace(regex, value); + }); + } - return acc.concat({ ...defaultConfig, ...series }); - }, []); + return { ...defaultConfig, ...series }; + }) + .filter(series => series !== null); diff --git a/app/assets/javascripts/ide/components/jobs/stage.vue b/app/assets/javascripts/ide/components/jobs/stage.vue index 9ad9d4455b5..52ca61c06b0 100644 --- a/app/assets/javascripts/ide/components/jobs/stage.vue +++ b/app/assets/javascripts/ide/components/jobs/stage.vue @@ -58,6 +58,7 @@ export default { <template> <div class="ide-stage card prepend-top-default"> <div + ref="cardHeader" :class="{ 'border-bottom-0': stage.isCollapsed, }" @@ -79,7 +80,7 @@ export default { </div> <icon :name="collapseIcon" class="ide-stage-collapse-icon" /> </div> - <div v-show="!stage.isCollapsed" class="card-body"> + <div v-show="!stage.isCollapsed" ref="jobList" class="card-body"> <gl-loading-icon v-if="showLoadingIcon" /> <template v-else> <item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" /> diff --git a/app/assets/javascripts/ide/components/preview/clientside.vue b/app/assets/javascripts/ide/components/preview/clientside.vue index 6999746f115..beb179d0411 100644 --- a/app/assets/javascripts/ide/components/preview/clientside.vue +++ b/app/assets/javascripts/ide/components/preview/clientside.vue @@ -92,6 +92,7 @@ export default { }, methods: { ...mapActions(['getFileData', 'getRawFileData']), + ...mapActions('clientside', ['pingUsage']), loadFileContent(path) { return this.getFileData({ path, makeFileActive: false }).then(() => this.getRawFileData({ path }), @@ -100,6 +101,8 @@ export default { initPreview() { if (!this.mainEntry) return null; + this.pingUsage(); + return this.loadFileContent(this.mainEntry) .then(() => this.$nextTick()) .then(() => { diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 3bf8308ccea..08b3e8a34d6 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -301,6 +301,7 @@ export default { v-if="showContentViewer" :content="file.content || file.raw" :path="file.rawPath || file.path" + :file-path="file.path" :file-size="file.size" :project-path="file.projectId" :type="fileType" diff --git a/app/assets/javascripts/ide/services/index.js b/app/assets/javascripts/ide/services/index.js index ba33b6826d6..f6ad2f9c7d1 100644 --- a/app/assets/javascripts/ide/services/index.js +++ b/app/assets/javascripts/ide/services/index.js @@ -1,4 +1,6 @@ import axios from '~/lib/utils/axios_utils'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { escapeFileUrl } from '../stores/utils'; import Api from '~/api'; export default { @@ -23,18 +25,25 @@ export default { .then(({ data }) => data); }, getBaseRawFileData(file, sha) { - if (file.tempFile) { - return Promise.resolve(file.baseRaw); - } + if (file.tempFile || file.baseRaw) return Promise.resolve(file.baseRaw); - if (file.baseRaw) { - return Promise.resolve(file.baseRaw); - } + // if files are renamed, their base path has changed + const filePath = + file.mrChange && file.mrChange.renamed_file ? file.mrChange.old_path : file.path; return axios - .get(file.rawPath.replace(`/raw/${file.branchId}/${file.path}`, `/raw/${sha}/${file.path}`), { - transformResponse: [f => f], - }) + .get( + joinPaths( + gon.relative_url_root || '/', + file.projectId, + 'raw', + sha, + escapeFileUrl(filePath), + ), + { + transformResponse: [f => f], + }, + ) .then(({ data }) => data); }, getProjectData(namespace, project) { @@ -58,8 +67,8 @@ export default { commit(projectId, payload) { return Api.commitMultiple(projectId, payload); }, - getFiles(projectUrl, branchId) { - const url = `${projectUrl}/files/${branchId}`; + getFiles(projectUrl, ref) { + const url = `${projectUrl}/files/${ref}`; return axios.get(url, { params: { format: 'json' } }); }, lastCommitPipelines({ getters }) { diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 59445afc7a4..9af0b50d1a5 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -1,11 +1,10 @@ import { joinPaths } from '~/lib/utils/url_utility'; -import { normalizeHeaders } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; -import { setPageTitle, replaceFileUrl } from '../utils'; +import { escapeFileUrl, addFinalNewlineIfNeeded, setPageTitleForFile } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { @@ -58,7 +57,7 @@ export const setFileActive = ({ commit, state, getters, dispatch }, path) => { }; export const getFileData = ( - { state, commit, dispatch }, + { state, commit, dispatch, getters }, { path, makeFileActive = true, openFile = makeFileActive }, ) => { const file = state.entries[path]; @@ -67,15 +66,18 @@ export const getFileData = ( commit(types.TOGGLE_LOADING, { entry: file }); - const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url; + const url = joinPaths( + gon.relative_url_root || '/', + state.currentProjectId, + file.type, + getters.lastCommit && getters.lastCommit.id, + escapeFileUrl(file.prevPath || file.path), + ); return service - .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/'))) - .then(({ data, headers }) => { - const normalizedHeaders = normalizeHeaders(headers); - let title = normalizedHeaders['PAGE-TITLE']; - title = file.prevPath ? title.replace(file.prevPath, file.path) : title; - setPageTitle(decodeURI(title)); + .getFileData(url) + .then(({ data }) => { + setPageTitleForFile(state, file); if (data) commit(types.SET_FILE_DATA, { data, file }); if (openFile) commit(types.TOGGLE_FILE_OPEN, path); @@ -140,7 +142,10 @@ export const getRawFileData = ({ state, commit, dispatch, getters }, { path }) = export const changeFileContent = ({ commit, dispatch, state }, { path, content }) => { const file = state.entries[path]; - commit(types.UPDATE_FILE_CONTENT, { path, content }); + commit(types.UPDATE_FILE_CONTENT, { + path, + content: addFinalNewlineIfNeeded(content), + }); const indexOfChangedFile = state.changedFiles.findIndex(f => f.path === path); diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 1273e375859..6790c0fbdaa 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -152,15 +152,17 @@ export const openMergeRequest = ( .then(mr => { dispatch('setCurrentBranchId', mr.source_branch); - dispatch('getBranchData', { + // getFiles needs to be called after getting the branch data + // since files are fetched using the last commit sha of the branch + return dispatch('getBranchData', { projectId, branchId: mr.source_branch, - }); - - return dispatch('getFiles', { - projectId, - branchId: mr.source_branch, - }); + }).then(() => + dispatch('getFiles', { + projectId, + branchId: mr.source_branch, + }), + ); }) .then(() => dispatch('getMergeRequestVersions', { diff --git a/app/assets/javascripts/ide/stores/actions/tree.js b/app/assets/javascripts/ide/stores/actions/tree.js index 75511574d3e..72cd099c5a5 100644 --- a/app/assets/javascripts/ide/stores/actions/tree.js +++ b/app/assets/javascripts/ide/stores/actions/tree.js @@ -46,7 +46,7 @@ export const setDirectoryData = ({ state, commit }, { projectId, branchId, treeL }); }; -export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = {}) => +export const getFiles = ({ state, commit, dispatch, getters }, { projectId, branchId } = {}) => new Promise((resolve, reject) => { if ( !state.trees[`${projectId}/${branchId}`] || @@ -54,10 +54,11 @@ export const getFiles = ({ state, commit, dispatch }, { projectId, branchId } = state.trees[`${projectId}/${branchId}`].tree.length === 0) ) { const selectedProject = state.projects[projectId]; - commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); + const selectedBranch = getters.findBranch(projectId, branchId); + commit(types.CREATE_TREE, { treePath: `${projectId}/${branchId}` }); service - .getFiles(selectedProject.web_url, branchId) + .getFiles(selectedProject.web_url, selectedBranch.commit.id) .then(({ data }) => { const { entries, treeList } = decorateFiles({ data, diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 85fd45358be..a176fd0aca8 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -34,7 +34,9 @@ export const currentMergeRequest = state => { return null; }; -export const currentProject = state => state.projects[state.currentProjectId]; +export const findProject = state => projectId => state.projects[projectId]; + +export const currentProject = (state, getters) => getters.findProject(state.currentProjectId); export const emptyRepo = state => state.projects[state.currentProjectId] && state.projects[state.currentProjectId].empty_repo; @@ -94,8 +96,14 @@ export const lastCommit = (state, getters) => { return branch ? branch.commit : null; }; +export const findBranch = (state, getters) => (projectId, branchId) => { + const project = getters.findProject(projectId); + + return project && project.branches[branchId]; +}; + export const currentBranch = (state, getters) => - getters.currentProject && getters.currentProject.branches[state.currentBranchId]; + getters.findBranch(state.currentProjectId, state.currentBranchId); export const branchName = (_state, getters) => getters.currentBranch && getters.currentBranch.name; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index f1f544b52b2..85550578e94 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -10,6 +10,7 @@ import mergeRequests from './modules/merge_requests'; import branches from './modules/branches'; import fileTemplates from './modules/file_templates'; import paneModule from './modules/pane'; +import clientsideModule from './modules/clientside'; Vue.use(Vuex); @@ -26,6 +27,7 @@ export const createStore = () => branches, fileTemplates: fileTemplates(), rightPane: paneModule(), + clientside: clientsideModule(), }, }); diff --git a/app/assets/javascripts/ide/stores/modules/clientside/actions.js b/app/assets/javascripts/ide/stores/modules/clientside/actions.js new file mode 100644 index 00000000000..eb3bcdff2ae --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/clientside/actions.js @@ -0,0 +1,12 @@ +import axios from '~/lib/utils/axios_utils'; + +export const pingUsage = ({ rootGetters }) => { + const { web_url: projectUrl } = rootGetters.currentProject; + + const url = `${projectUrl}/usage_ping/web_ide_clientside_preview`; + + return axios.post(url); +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/clientside/index.js b/app/assets/javascripts/ide/stores/modules/clientside/index.js new file mode 100644 index 00000000000..b28f7b935a8 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/clientside/index.js @@ -0,0 +1,6 @@ +import * as actions from './actions'; + +export default () => ({ + namespaced: true, + actions, +}); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index a8d8ff31afe..be7ee80656f 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -113,6 +113,11 @@ export const setPageTitle = title => { document.title = title; }; +export const setPageTitleForFile = (state, file) => { + const title = [file.path, state.currentBranchId, state.currentProjectId, 'GitLab'].join(' · '); + setPageTitle(title); +}; + export const commitActionForFile = file => { if (file.prevPath) { return commitActionTypes.move; @@ -269,3 +274,7 @@ export const pathsAreEqual = (a, b) => { return cleanA === cleanB; }; + +// if the contents of a file dont end with a newline, this function adds a newline +export const addFinalNewlineIfNeeded = content => + content.charAt(content.length - 1) !== '\n' ? `${content}\n` : content; diff --git a/app/assets/javascripts/issuable_bulk_update_sidebar.js b/app/assets/javascripts/issuable_bulk_update_sidebar.js index 74150ce3a8b..bd6e8433544 100644 --- a/app/assets/javascripts/issuable_bulk_update_sidebar.js +++ b/app/assets/javascripts/issuable_bulk_update_sidebar.js @@ -1,11 +1,13 @@ /* eslint-disable class-methods-use-this, no-new */ import $ from 'jquery'; +import { property } from 'underscore'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import MilestoneSelect from './milestone_select'; import issueStatusSelect from './issue_status_select'; import subscriptionSelect from './subscription_select'; import LabelsSelect from './labels_select'; +import issueableEventHub from './issuables_list/eventhub'; const HIDDEN_CLASS = 'hidden'; const DISABLED_CONTENT_CLASS = 'disabled-content'; @@ -14,6 +16,8 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si export default class IssuableBulkUpdateSidebar { constructor() { + this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window); + this.initDomElements(); this.bindEvents(); this.initDropdowns(); @@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar { this.$issuesList.on('change', () => this.updateFormState()); this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit()); this.$checkAllContainer.on('click', () => this.updateFormState()); + + if (this.vueIssuablesListFeature) { + issueableEventHub.$on('issuables:updateBulkEdit', () => { + // Danger! Strong coupling ahead! + // The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue + // is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties + // explicitly, but this component is used in too many places right now to refactor straight away. + + this.updateFormState(); + }); + } } initDropdowns() { @@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar { toggleBulkEdit(e, enable) { e.preventDefault(); + issueableEventHub.$emit('issuables:toggleBulkEdit', enable); + this.toggleSidebarDisplay(enable); this.toggleBulkEditButtonDisabled(enable); this.toggleOtherFiltersDisabled(enable); @@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar { } toggleCheckboxDisplay(show) { - this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show); + this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature); this.$issueChecks.toggleClass(HIDDEN_CLASS, !show); } diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue new file mode 100644 index 00000000000..eb924609a8a --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -0,0 +1,335 @@ +<script> +/* + * This is tightly coupled to projects/issues/_issue.html.haml, + * any changes done to the haml need to be reflected here. + */ +import { escape, isNumber } from 'underscore'; +import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { + dateInWords, + formatDate, + getDayDifference, + getTimeago, + timeFor, + newDateAsLocaleTime, +} from '~/lib/utils/datetime_utility'; +import { sprintf, __ } from '~/locale'; +import initUserPopovers from '~/user_popovers'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import Icon from '~/vue_shared/components/icon.vue'; +import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; + +const ISSUE_TOKEN = '#'; + +export default { + components: { + Icon, + IssueAssignees, + GlLink, + }, + directives: { + GlTooltip, + }, + props: { + issuable: { + type: Object, + required: true, + }, + isBulkEditing: { + type: Boolean, + required: false, + default: false, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + baseUrl: { + type: String, + required: false, + default() { + return window.location.href; + }, + }, + }, + computed: { + milestoneLink() { + const { title } = this.issuable.milestone; + + return this.issuableLink({ milestone_title: title }); + }, + hasLabels() { + return Boolean(this.issuable.labels && this.issuable.labels.length); + }, + hasWeight() { + return isNumber(this.issuable.weight); + }, + dueDate() { + return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined; + }, + dueDateWords() { + return this.dueDate ? dateInWords(this.dueDate, true) : undefined; + }, + hasNoComments() { + return !this.userNotesCount; + }, + isOverdue() { + return this.dueDate ? this.dueDate < new Date() : false; + }, + isClosed() { + return this.issuable.state === 'closed'; + }, + issueCreatedToday() { + return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1; + }, + labelIdsString() { + return JSON.stringify(this.issuable.labels.map(l => l.id)); + }, + milestoneDueDate() { + const { due_date: dueDate } = this.issuable.milestone || {}; + + return dueDate ? newDateAsLocaleTime(dueDate) : undefined; + }, + milestoneTooltipText() { + if (this.milestoneDueDate) { + return sprintf(__('%{primary} (%{secondary})'), { + primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'), + secondary: timeFor(this.milestoneDueDate), + }); + } + return __('Milestone'); + }, + openedAgoByString() { + const { author, created_at } = this.issuable; + + return sprintf( + __('opened %{timeAgoString} by %{user}'), + { + timeAgoString: escape(getTimeago().format(created_at)), + user: `<a href="${escape(author.web_url)}" + data-user-id=${escape(author.id)} + data-username=${escape(author.username)} + data-name=${escape(author.name)} + data-avatar-url="${escape(author.avatar_url)}"> + ${escape(author.name)} + </a>`, + }, + false, + ); + }, + referencePath() { + // TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301 + return `${ISSUE_TOKEN}${this.issuable.iid}`; + }, + updatedDateString() { + return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt'); + }, + updatedDateAgo() { + // snake_case because it's the same i18n string as the HAML view + return sprintf(__('updated %{time_ago}'), { + time_ago: escape(getTimeago().format(this.issuable.updated_at)), + }); + }, + userNotesCount() { + return this.issuable.user_notes_count; + }, + issuableMeta() { + return [ + { + key: 'merge-requests', + value: this.issuable.merge_requests_count, + title: __('Related merge requests'), + class: 'js-merge-requests', + icon: 'merge-request', + }, + { + key: 'upvotes', + value: this.issuable.upvotes, + title: __('Upvotes'), + class: 'js-upvotes', + faicon: 'fa-thumbs-up', + }, + { + key: 'downvotes', + value: this.issuable.downvotes, + title: __('Downvotes'), + class: 'js-downvotes', + faicon: 'fa-thumbs-down', + }, + ]; + }, + }, + mounted() { + // TODO: Refactor user popover to use its own component instead of + // spawning event listeners on Vue-rendered elements. + initUserPopovers([this.$refs.openedAgoByContainer.querySelector('a')]); + }, + methods: { + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.text_color, + }; + }, + issuableLink(params) { + return mergeUrlParams(params, this.baseUrl); + }, + labelHref({ name }) { + return this.issuableLink({ 'label_name[]': name }); + }, + onSelect(ev) { + this.$emit('select', { + issuable: this.issuable, + selected: ev.target.checked, + }); + }, + }, + + confidentialTooltipText: __('Confidential'), +}; +</script> +<template> + <li + :id="`issue_${issuable.id}`" + class="issue" + :class="{ today: issueCreatedToday, closed: isClosed }" + :data-id="issuable.id" + :data-labels="labelIdsString" + :data-url="issuable.web_url" + > + <div class="d-flex"> + <!-- Bulk edit checkbox --> + <div v-if="isBulkEditing" class="mr-2"> + <input + :checked="selected" + class="selected-issuable" + type="checkbox" + :data-id="issuable.id" + @input="onSelect" + /> + </div> + + <!-- Issuable info container --> + <!-- Issuable main info --> + <div class="flex-grow-1"> + <div class="title"> + <span class="issue-title-text"> + <i + v-if="issuable.confidential" + v-gl-tooltip + class="fa fa-eye-slash" + :title="$options.confidentialTooltipText" + :aria-label="$options.confidentialTooltipText" + ></i> + <gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link> + </span> + <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">{{ + issuable.task_status + }}</span> + </div> + + <div class="issuable-info"> + <span>{{ referencePath }}</span> + + <span class="d-none d-sm-inline-block mr-1"> + · + <span ref="openedAgoByContainer" v-html="openedAgoByString"></span> + </span> + + <gl-link + v-if="issuable.milestone" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-milestone" + :href="milestoneLink" + :title="milestoneTooltipText" + > + <i class="fa fa-clock-o"></i> + {{ issuable.milestone.title }} + </gl-link> + + <span + v-if="dueDate" + v-gl-tooltip + class="d-none d-sm-inline-block mr-1 js-due-date" + :class="{ cred: isOverdue }" + :title="__('Due date')" + > + <i class="fa fa-calendar"></i> + {{ dueDateWords }} + </span> + + <span v-if="hasLabels" class="js-labels"> + <gl-link + v-for="label in issuable.labels" + :key="label.id" + class="label-link mr-1" + :href="labelHref(label)" + > + <span + v-gl-tooltip + class="badge color-label" + :style="labelStyle(label)" + :title="label.description" + >{{ label.name }}</span + > + </gl-link> + </span> + + <span + v-if="hasWeight" + v-gl-tooltip + :title="__('Weight')" + class="d-none d-sm-inline-block js-weight" + > + <icon name="weight" class="align-text-bottom" /> + {{ issuable.weight }} + </span> + </div> + </div> + + <!-- Issuable meta --> + <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center"> + <div class="controls d-flex"> + <span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> + + <issue-assignees + :assignees="issuable.assignees" + class="align-items-center d-flex ml-2" + :icon-size="16" + img-css-classes="mr-1" + :max-visible="4" + /> + + <template v-for="meta in issuableMeta"> + <span + v-if="meta.value" + :key="meta.key" + v-gl-tooltip + :class="['d-none d-sm-inline-block ml-2', meta.class]" + :title="meta.title" + > + <icon v-if="meta.icon" :name="meta.icon" /> + <i v-else :class="['fa', meta.faicon]"></i> + {{ meta.value }} + </span> + </template> + + <gl-link + v-gl-tooltip + class="ml-2 js-notes" + :href="`${issuable.web_url}#notes`" + :title="__('Comments')" + :class="{ 'no-comments': hasNoComments }" + > + <i class="fa fa-comments"></i> + {{ userNotesCount }} + </gl-link> + </div> + <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> + {{ updatedDateAgo }} + </div> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue new file mode 100644 index 00000000000..6b6a8bd4068 --- /dev/null +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -0,0 +1,277 @@ +<script> +import { omit } from 'underscore'; +import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; +import flash from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import { scrollToElement, urlParamsToObject } from '~/lib/utils/common_utils'; +import { __ } from '~/locale'; +import initManualOrdering from '~/manual_ordering'; +import Issuable from './issuable.vue'; +import { + sortOrderMap, + RELATIVE_POSITION, + PAGE_SIZE, + PAGE_SIZE_MANUAL, + LOADING_LIST_ITEMS_LENGTH, +} from '../constants'; +import issueableEventHub from '../eventhub'; + +export default { + LOADING_LIST_ITEMS_LENGTH, + components: { + GlEmptyState, + GlPagination, + GlSkeletonLoading, + Issuable, + }, + props: { + canBulkEdit: { + type: Boolean, + required: false, + default: false, + }, + createIssuePath: { + type: String, + required: false, + default: '', + }, + emptySvgPath: { + type: String, + required: false, + default: '', + }, + endpoint: { + type: String, + required: true, + }, + sortKey: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + filters: {}, + isBulkEditing: false, + issuables: [], + loading: false, + page: 1, + selection: {}, + totalItems: 0, + }; + }, + computed: { + allIssuablesSelected() { + // WARNING: Because we are only keeping track of selected values + // this works, we will need to rethink this if we start tracking + // [id]: false for not selected values. + return this.issuables.length === Object.keys(this.selection).length; + }, + emptyState() { + if (this.issuables.length) { + return {}; // Empty state shouldn't be shown here + } else if (this.hasFilters) { + return { + title: __('Sorry, your filter produced no results'), + description: __('To widen your search, change or remove filters above'), + }; + } else if (this.filters.state === 'opened') { + return { + title: __('There are no open issues'), + description: __('To keep this project going, create a new issue'), + primaryLink: this.createIssuePath, + primaryText: __('New issue'), + }; + } else if (this.filters.state === 'closed') { + return { + title: __('There are no closed issues'), + }; + } + + return { + title: __('There are no issues to show'), + description: __( + 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', + ), + }; + }, + hasFilters() { + const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort']; + return Object.keys(omit(this.filters, ignored)).length > 0; + }, + isManualOrdering() { + return this.sortKey === RELATIVE_POSITION; + }, + itemsPerPage() { + return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE; + }, + baseUrl() { + return window.location.href.replace(/(\?.*)?(#.*)?$/, ''); + }, + }, + watch: { + selection() { + // We need to call nextTick here to wait for all of the boxes to be checked and rendered + // before we query the dom in issuable_bulk_update_actions.js. + this.$nextTick(() => { + issueableEventHub.$emit('issuables:updateBulkEdit'); + }); + }, + issuables() { + this.$nextTick(() => { + initManualOrdering(); + }); + }, + }, + mounted() { + if (this.canBulkEdit) { + this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', val => { + this.isBulkEditing = val; + }); + } + this.fetchIssuables(); + }, + beforeDestroy() { + issueableEventHub.$off('issuables:toggleBulkEdit'); + }, + methods: { + isSelected(issuableId) { + return Boolean(this.selection[issuableId]); + }, + setSelection(ids) { + ids.forEach(id => { + this.select(id, true); + }); + }, + clearSelection() { + this.selection = {}; + }, + select(id, isSelect = true) { + if (isSelect) { + this.$set(this.selection, id, true); + } else { + this.$delete(this.selection, id); + } + }, + fetchIssuables(pageToFetch) { + this.loading = true; + + this.clearSelection(); + + this.setFilters(); + + return axios + .get(this.endpoint, { + params: { + ...this.filters, + + with_labels_details: true, + page: pageToFetch || this.page, + per_page: this.itemsPerPage, + }, + }) + .then(response => { + this.loading = false; + this.issuables = response.data; + this.totalItems = Number(response.headers['x-total']); + this.page = Number(response.headers['x-page']); + }) + .catch(() => { + this.loading = false; + return flash(__('An error occurred while loading issues')); + }); + }, + getQueryObject() { + return urlParamsToObject(window.location.search); + }, + onPaginate(newPage) { + if (newPage === this.page) return; + + scrollToElement('#content-body'); + this.fetchIssuables(newPage); + }, + onSelectAll() { + if (this.allIssuablesSelected) { + this.selection = {}; + } else { + this.setSelection(this.issuables.map(({ id }) => id)); + } + }, + onSelectIssuable({ issuable, selected }) { + if (!this.canBulkEdit) return; + + this.select(issuable.id, selected); + }, + setFilters() { + const { + label_name: labels, + milestone_title: milestoneTitle, + ...filters + } = this.getQueryObject(); + + if (milestoneTitle) { + filters.milestone = milestoneTitle; + } + if (Array.isArray(labels)) { + filters.labels = labels.join(','); + } + if (!filters.state) { + filters.state = 'opened'; + } + + Object.assign(filters, sortOrderMap[this.sortKey]); + + this.filters = filters; + }, + }, +}; +</script> + +<template> + <ul v-if="loading" class="content-list"> + <li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue"> + <gl-skeleton-loading /> + </li> + </ul> + <div v-else-if="issuables.length"> + <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> + <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> + <strong>{{ __('Select all') }}</strong> + </div> + <ul + class="content-list issuable-list issues-list" + :class="{ 'manual-ordering': isManualOrdering }" + > + <issuable + v-for="issuable in issuables" + :key="issuable.id" + class="pr-3" + :class="{ 'user-can-drag': isManualOrdering }" + :issuable="issuable" + :is-bulk-editing="isBulkEditing" + :selected="isSelected(issuable.id)" + :base-url="baseUrl" + @select="onSelectIssuable" + /> + </ul> + <div class="mt-3"> + <gl-pagination + v-if="totalItems" + :value="page" + :per-page="itemsPerPage" + :total-items="totalItems" + class="justify-content-center" + @input="onPaginate" + /> + </div> + </div> + <gl-empty-state + v-else + :title="emptyState.title" + :description="emptyState.description" + :svg-path="emptySvgPath" + :primary-button-link="emptyState.primaryLink" + :primary-button-text="emptyState.primaryText" + /> +</template> diff --git a/app/assets/javascripts/issuables_list/constants.js b/app/assets/javascripts/issuables_list/constants.js new file mode 100644 index 00000000000..71b9c52c703 --- /dev/null +++ b/app/assets/javascripts/issuables_list/constants.js @@ -0,0 +1,33 @@ +// Maps sort order as it appears in the URL query to API `order_by` and `sort` params. +const PRIORITY = 'priority'; +const ASC = 'asc'; +const DESC = 'desc'; +const CREATED_AT = 'created_at'; +const UPDATED_AT = 'updated_at'; +const DUE_DATE = 'due_date'; +const MILESTONE_DUE = 'milestone_due'; +const POPULARITY = 'popularity'; +const WEIGHT = 'weight'; +const LABEL_PRIORITY = 'label_priority'; +export const RELATIVE_POSITION = 'relative_position'; +export const LOADING_LIST_ITEMS_LENGTH = 8; +export const PAGE_SIZE = 20; +export const PAGE_SIZE_MANUAL = 100; + +export const sortOrderMap = { + priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason + created_date: { order_by: CREATED_AT, sort: DESC }, + created_asc: { order_by: CREATED_AT, sort: ASC }, + updated_desc: { order_by: UPDATED_AT, sort: DESC }, + updated_asc: { order_by: UPDATED_AT, sort: ASC }, + milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC }, + milestone: { order_by: MILESTONE_DUE, sort: ASC }, + due_date_desc: { order_by: DUE_DATE, sort: DESC }, + due_date: { order_by: DUE_DATE, sort: ASC }, + popularity: { order_by: POPULARITY, sort: DESC }, + popularity_asc: { order_by: POPULARITY, sort: ASC }, + label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped + relative_position: { order_by: RELATIVE_POSITION, sort: ASC }, + weight_desc: { order_by: WEIGHT, sort: DESC }, + weight: { order_by: WEIGHT, sort: ASC }, +}; diff --git a/app/assets/javascripts/issuables_list/eventhub.js b/app/assets/javascripts/issuables_list/eventhub.js new file mode 100644 index 00000000000..d1601a7d8f3 --- /dev/null +++ b/app/assets/javascripts/issuables_list/eventhub.js @@ -0,0 +1,5 @@ +import Vue from 'vue'; + +const issueablesEventBus = new Vue(); + +export default issueablesEventBus; diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js new file mode 100644 index 00000000000..9fc7fa837ff --- /dev/null +++ b/app/assets/javascripts/issuables_list/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import IssuablesListApp from './components/issuables_list_app.vue'; + +export default function initIssuablesList() { + if (!gon.features || !gon.features.vueIssuablesList) { + return; + } + + document.querySelectorAll('.js-issuables-list').forEach(el => { + const { canBulkEdit, ...data } = el.dataset; + + const props = { + ...data, + canBulkEdit: Boolean(canBulkEdit), + }; + + return new Vue({ + el, + render(createElement) { + return createElement(IssuablesListApp, { props }); + }, + }); + }); +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index a9e086fade8..9136a47d542 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, one-var, consistent-return */ +/* eslint-disable consistent-return */ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; @@ -91,18 +91,17 @@ export default class Issue { 'click', '.js-issuable-actions a.btn-close, .js-issuable-actions a.btn-reopen', e => { - var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); - $button = $(e.currentTarget); - shouldSubmit = $button.hasClass('btn-comment'); + const $button = $(e.currentTarget); + const shouldSubmit = $button.hasClass('btn-comment'); if (shouldSubmit) { Issue.submitNoteForm($button.closest('form')); } this.disableCloseReopenButton($button); - url = $button.attr('href'); + const url = $button.attr('href'); return axios .put(url) .then(({ data }) => { @@ -139,16 +138,14 @@ export default class Issue { } static submitNoteForm(form) { - var noteText; - noteText = form.find('textarea.js-note-text').val(); + const noteText = form.find('textarea.js-note-text').val(); if (noteText && noteText.trim().length > 0) { return form.submit(); } } static initRelatedBranches() { - var $container; - $container = $('#related-branches'); + const $container = $('#related-branches'); return axios .get($container.data('url')) .then(({ data }) => { diff --git a/app/assets/javascripts/jobs/components/log/log.vue b/app/assets/javascripts/jobs/components/log/log.vue index ef126166e8b..03a697d11ed 100644 --- a/app/assets/javascripts/jobs/components/log/log.vue +++ b/app/assets/javascripts/jobs/components/log/log.vue @@ -11,11 +11,35 @@ export default { computed: { ...mapState(['traceEndpoint', 'trace', 'isTraceComplete']), }, + updated() { + this.$nextTick(() => { + this.handleScrollDown(); + }); + }, + mounted() { + this.$nextTick(() => { + this.handleScrollDown(); + }); + }, methods: { - ...mapActions(['toggleCollapsibleLine']), + ...mapActions(['toggleCollapsibleLine', 'scrollBottom']), handleOnClickCollapsibleLine(section) { this.toggleCollapsibleLine(section); }, + /** + * The job log is sent in HTML, which means we need to use `v-html` to render it + * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated + * in this case because it runs before `v-html` has finished running, since there's no + * Vue binding. + * In order to scroll the page down after `v-html` has finished, we need to use setTimeout + */ + handleScrollDown() { + if (this.isScrolledToBottomBeforeReceivingTrace) { + setTimeout(() => { + this.scrollBottom(); + }, 0); + } + }, }, }; </script> diff --git a/app/assets/javascripts/jobs/store/utils.js b/app/assets/javascripts/jobs/store/utils.js index 58e49f54d96..179d0bc4e0f 100644 --- a/app/assets/javascripts/jobs/store/utils.js +++ b/app/assets/javascripts/jobs/store/utils.js @@ -17,7 +17,7 @@ export const parseLine = (line = {}, lineNumber) => ({ * @param Number lineNumber */ export const parseHeaderLine = (line = {}, lineNumber) => ({ - isClosed: true, + isClosed: false, isHeader: true, line: parseLine(line, lineNumber), lines: [], diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 72de3b5d726..6abf723be9a 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-return, func-names, no-var, no-underscore-dangle, one-var, no-new, consistent-return, no-shadow, no-param-reassign, vars-on-top, no-lonely-if, no-else-return, dot-notation, no-empty */ +/* eslint-disable no-useless-return, func-names, no-underscore-dangle, no-new, consistent-return, no-shadow, no-param-reassign, no-lonely-if, no-else-return, dot-notation, no-empty */ /* global Issuable */ /* global ListLabel */ @@ -15,63 +15,39 @@ import { isScopedLabel } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { - var _this, $els; - _this = this; + const _this = this; - $els = $(els); + let $els = $(els); if (!els) { $els = $('.js-label-select'); } $els.each((i, dropdown) => { - var $block, - $dropdown, - $form, - $loading, - $selectbox, - $sidebarCollapsedValue, - $value, - $dropdownMenu, - abilityName, - defaultLabel, - issueUpdateURL, - labelUrl, - namespacePath, - projectPath, - saveLabelData, - selectedLabel, - showAny, - showNo, - $sidebarLabelTooltip, - initialSelected, - fieldName, - showMenuAbove, - $dropdownContainer; - $dropdown = $(dropdown); - $dropdownContainer = $dropdown.closest('.labels-filter'); - namespacePath = $dropdown.data('namespacePath'); - projectPath = $dropdown.data('projectPath'); - issueUpdateURL = $dropdown.data('issueUpdate'); - selectedLabel = $dropdown.data('selected'); + const $dropdown = $(dropdown); + const $dropdownContainer = $dropdown.closest('.labels-filter'); + const namespacePath = $dropdown.data('namespacePath'); + const projectPath = $dropdown.data('projectPath'); + const issueUpdateURL = $dropdown.data('issueUpdate'); + let selectedLabel = $dropdown.data('selected'); if (selectedLabel != null && !$dropdown.hasClass('js-multiselect')) { selectedLabel = selectedLabel.split(','); } - showNo = $dropdown.data('showNo'); - showAny = $dropdown.data('showAny'); - showMenuAbove = $dropdown.data('showMenuAbove'); - defaultLabel = $dropdown.data('defaultLabel') || __('Label'); - abilityName = $dropdown.data('abilityName'); - $selectbox = $dropdown.closest('.selectbox'); - $block = $selectbox.closest('.block'); - $form = $dropdown.closest('form, .js-issuable-update'); - $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); - $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); - $value = $block.find('.value'); - $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); - $loading = $block.find('.block-loading').fadeOut(); - fieldName = $dropdown.data('fieldName'); - initialSelected = $selectbox + const showNo = $dropdown.data('showNo'); + const showAny = $dropdown.data('showAny'); + const showMenuAbove = $dropdown.data('showMenuAbove'); + const defaultLabel = $dropdown.data('defaultLabel') || __('Label'); + const abilityName = $dropdown.data('abilityName'); + const $selectbox = $dropdown.closest('.selectbox'); + const $block = $selectbox.closest('.block'); + const $form = $dropdown.closest('form, .js-issuable-update'); + const $sidebarCollapsedValue = $block.find('.sidebar-collapsed-icon span'); + const $sidebarLabelTooltip = $block.find('.js-sidebar-labels-tooltip'); + const $value = $block.find('.value'); + const $dropdownMenu = $dropdown.parent().find('.dropdown-menu'); + const $loading = $block.find('.block-loading').fadeOut(); + const fieldName = $dropdown.data('fieldName'); + let initialSelected = $selectbox .find(`input[name="${$dropdown.data('fieldName')}"]`) .map(function() { return this.value; @@ -90,9 +66,8 @@ export default class LabelsSelect { ); } - saveLabelData = function() { - var data, selected; - selected = $dropdown + const saveLabelData = function() { + const selected = $dropdown .closest('.selectbox') .find(`input[name='${fieldName}']`) .map(function() { @@ -103,7 +78,7 @@ export default class LabelsSelect { if (_.isEqual(initialSelected, selected)) return; initialSelected = selected; - data = {}; + const data = {}; data[abilityName] = {}; data[abilityName].label_ids = selected; if (!selected.length) { @@ -114,12 +89,13 @@ export default class LabelsSelect { axios .put(issueUpdateURL, data) .then(({ data }) => { - var labelCount, template, labelTooltipTitle, labelTitles; + let labelTooltipTitle; + let template; $loading.fadeOut(); $dropdown.trigger('loaded.gl.dropdown'); $selectbox.hide(); data.issueUpdateURL = issueUpdateURL; - labelCount = 0; + let labelCount = 0; if (data.labels.length && issueUpdateURL) { template = LabelsSelect.getLabelTemplate({ labels: _.sortBy(data.labels, 'title'), @@ -174,7 +150,7 @@ export default class LabelsSelect { $sidebarCollapsedValue.text(labelCount); if (data.labels.length) { - labelTitles = data.labels.map(label => label.title); + let labelTitles = data.labels.map(label => label.title); if (labelTitles.length > 5) { labelTitles = labelTitles.slice(0, 5); @@ -199,13 +175,13 @@ export default class LabelsSelect { $dropdown.glDropdown({ showMenuAbove, data(term, callback) { - labelUrl = $dropdown.attr('data-labels'); + const labelUrl = $dropdown.attr('data-labels'); axios .get(labelUrl) .then(res => { let { data } = res; if ($dropdown.hasClass('js-extra-options')) { - var extraData = []; + const extraData = []; if (showNo) { extraData.unshift({ id: 0, @@ -232,22 +208,14 @@ export default class LabelsSelect { .catch(() => flash(__('Error fetching labels.'))); }, renderRow(label) { - var linkEl, - listItemEl, - colorEl, - indeterminate, - removesAll, - selectedClass, - i, - marked, - dropdownValue; - - selectedClass = []; - removesAll = label.id <= 0 || label.id == null; + let colorEl; + + const selectedClass = []; + const removesAll = label.id <= 0 || label.id == null; if ($dropdown.hasClass('js-filter-bulk-update')) { - indeterminate = $dropdown.data('indeterminate') || []; - marked = $dropdown.data('marked') || []; + const indeterminate = $dropdown.data('indeterminate') || []; + const marked = $dropdown.data('marked') || []; if (indeterminate.indexOf(label.id) !== -1) { selectedClass.push('is-indeterminate'); @@ -255,7 +223,7 @@ export default class LabelsSelect { if (marked.indexOf(label.id) !== -1) { // Remove is-indeterminate class if the item will be marked as active - i = selectedClass.indexOf('is-indeterminate'); + const i = selectedClass.indexOf('is-indeterminate'); if (i !== -1) { selectedClass.splice(i, 1); } @@ -263,7 +231,7 @@ export default class LabelsSelect { } } else { if (this.id(label)) { - dropdownValue = this.id(label) + const dropdownValue = this.id(label) .toString() .replace(/'/g, "\\'"); @@ -287,7 +255,7 @@ export default class LabelsSelect { colorEl = ''; } - linkEl = document.createElement('a'); + const linkEl = document.createElement('a'); linkEl.href = '#'; // We need to identify which items are actually labels @@ -300,7 +268,7 @@ export default class LabelsSelect { linkEl.className = selectedClass.join(' '); linkEl.innerHTML = `${colorEl} ${_.escape(label.title)}`; - listItemEl = document.createElement('li'); + const listItemEl = document.createElement('li'); listItemEl.appendChild(linkEl); return listItemEl; @@ -312,12 +280,12 @@ export default class LabelsSelect { filterable: true, selected: $dropdown.data('selected') || [], toggleLabel(selected, el) { - var $dropdownParent = $dropdown.parent(); - var $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); - var isSelected = el !== null ? el.hasClass('is-active') : false; + const $dropdownParent = $dropdown.parent(); + const $dropdownInputField = $dropdownParent.find('.dropdown-input-field'); + const isSelected = el !== null ? el.hasClass('is-active') : false; - var title = selected ? selected.title : null; - var selectedLabels = this.selected; + const title = selected ? selected.title : null; + const selectedLabels = this.selected; if ($dropdownInputField.length && $dropdownInputField.val().length) { $dropdownParent.find('.dropdown-input-clear').trigger('click'); @@ -329,7 +297,7 @@ export default class LabelsSelect { } else if (isSelected) { this.selected.push(title); } else if (!isSelected && title) { - var index = this.selected.indexOf(title); + const index = this.selected.indexOf(title); this.selected.splice(index, 1); } @@ -359,10 +327,9 @@ export default class LabelsSelect { } }, hidden() { - var isIssueIndex, isMRIndex, page; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; $selectbox.hide(); // display:block overrides the hide-collapse rule $value.removeAttr('style'); @@ -393,14 +360,13 @@ export default class LabelsSelect { const { $el, e, isMarking } = clickEvent; const label = clickEvent.selectedObj; - var isIssueIndex, isMRIndex, page, boardsModel; - var fadeOutLoader = () => { + const fadeOutLoader = () => { $loading.fadeOut(); }; - page = $('body').attr('data-page'); - isIssueIndex = page === 'projects:issues:index'; - isMRIndex = page === 'projects:merge_requests:index'; + const page = $('body').attr('data-page'); + const isIssueIndex = page === 'projects:issues:index'; + const isMRIndex = page === 'projects:merge_requests:index'; if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { $dropdown @@ -419,6 +385,7 @@ export default class LabelsSelect { return; } + let boardsModel; if ($dropdown.closest('.add-issues-modal').length) { boardsModel = ModalStore.store.filter; } @@ -450,7 +417,7 @@ export default class LabelsSelect { }), ); } else { - var { labels } = boardsStore.detail.issue; + let { labels } = boardsStore.detail.issue; labels = labels.filter(selectedLabel => selectedLabel.id !== label.id); boardsStore.detail.issue.labels = labels; } @@ -578,16 +545,14 @@ export default class LabelsSelect { } // eslint-disable-next-line class-methods-use-this setDropdownData($dropdown, isMarking, value) { - var i, markedIds, unmarkedIds, indeterminateIds; - - markedIds = $dropdown.data('marked') || []; - unmarkedIds = $dropdown.data('unmarked') || []; - indeterminateIds = $dropdown.data('indeterminate') || []; + const markedIds = $dropdown.data('marked') || []; + const unmarkedIds = $dropdown.data('unmarked') || []; + const indeterminateIds = $dropdown.data('indeterminate') || []; if (isMarking) { markedIds.push(value); - i = indeterminateIds.indexOf(value); + let i = indeterminateIds.indexOf(value); if (i > -1) { indeterminateIds.splice(i, 1); } @@ -598,7 +563,7 @@ export default class LabelsSelect { } } else { // If marked item (not common) is unmarked - i = markedIds.indexOf(value); + const i = markedIds.indexOf(value); if (i > -1) { markedIds.splice(i, 1); } diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index c05db4a5c71..2c5278d16ae 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -26,7 +26,11 @@ export default (resolvers = {}, config = {}) => { createUploadLink(httpOptions), new BatchHttpLink(httpOptions), ), - cache: new InMemoryCache(config.cacheConfig), + cache: new InMemoryCache({ + ...config.cacheConfig, + freezeResults: config.assumeImmutableResults, + }), resolvers, + assumeImmutableResults: config.assumeImmutableResults, }); }; diff --git a/app/assets/javascripts/lib/utils/chart_utils.js b/app/assets/javascripts/lib/utils/chart_utils.js index 0f78756aac8..4a1e6c5d68c 100644 --- a/app/assets/javascripts/lib/utils/chart_utils.js +++ b/app/assets/javascripts/lib/utils/chart_utils.js @@ -81,3 +81,20 @@ export const lineChartOptions = ({ width, numberOfPoints, shouldAdjustFontSize } }, }, }); + +/** + * Takes a dataset and returns an array containing the y-values of it's first and last entry. + * (e.g., [['xValue1', 'yValue1'], ['xValue2', 'yValue2'], ['xValue3', 'yValue3']] will yield ['yValue1', 'yValue3']) + * + * @param {Array} data + * @returns {[*, *]} + */ +export const firstAndLastY = data => { + const [firstEntry] = data; + const [lastEntry] = data.slice(-1); + + const firstY = firstEntry[1]; + const lastY = lastEntry[1]; + + return [firstY, lastY]; +}; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 37b0215f6f9..28143859e4c 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -78,11 +78,11 @@ export const getDayName = date => * @param {date} datetime * @returns {String} */ -export const formatDate = datetime => { +export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => { if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) { throw new Error(__('Invalid date')); } - return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z'); + return dateFormat(datetime, format); }; /** @@ -541,7 +541,7 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => { * The result cannot become negative. * * @param endDate date string that the time difference is calculated for - * @return {number} number of milliseconds remaining until the given date + * @return {Number} number of milliseconds remaining until the given date */ export const calculateRemainingMilliseconds = endDate => { const remainingMilliseconds = new Date(endDate).getTime() - Date.now(); @@ -552,15 +552,53 @@ export const calculateRemainingMilliseconds = endDate => { * Subtracts a given number of days from a given date and returns the new date. * * @param {Date} date the date that we will substract days from - * @param {number} daysInPast number of days that are subtracted from a given date - * @returns {String} Date string in ISO format + * @param {Number} daysInPast number of days that are subtracted from a given date + * @returns {Date} Date in past as Date object */ -export const getDateInPast = (date, daysInPast) => { - const dateClone = newDate(date); - return new Date( - dateClone.setTime(dateClone.getTime() - daysInPast * 24 * 60 * 60 * 1000), - ).toISOString(); +export const getDateInPast = (date, daysInPast) => + new Date(newDate(date).setDate(date.getDate() - daysInPast)); + +/* + * Appending T00:00:00 makes JS assume local time and prevents it from shifting the date + * to match the user's time zone. We want to display the date in server time for now, to + * be consistent with the "edit issue -> due date" UI. + */ + +export const newDateAsLocaleTime = date => { + const suffix = 'T00:00:00'; + return new Date(`${date}${suffix}`); }; export const beginOfDayTime = 'T00:00:00Z'; export const endOfDayTime = 'T23:59:59Z'; + +/** + * @param {Date} d1 + * @param {Date} d2 + * @param {Function} formatter + * @return {Any[]} an array of formatted dates between 2 given dates (including start&end date) + */ +export const getDatesInRange = (d1, d2, formatter = x => x) => { + if (!(d1 instanceof Date) || !(d2 instanceof Date)) { + return []; + } + let startDate = d1.getTime(); + const endDate = d2.getTime(); + const oneDay = 24 * 3600 * 1000; + const range = [d1]; + + while (startDate < endDate) { + startDate += oneDay; + range.push(new Date(startDate)); + } + + return range.map(formatter); +}; + +/** + * Converts the supplied number of seconds to milliseconds. + * + * @param {Number} seconds + * @return {Number} number of milliseconds + */ +export const secondsToMilliseconds = seconds => seconds * 1000; diff --git a/app/assets/javascripts/lib/utils/notify.js b/app/assets/javascripts/lib/utils/notify.js index cd509a13193..8db08099b3f 100644 --- a/app/assets/javascripts/lib/utils/notify.js +++ b/app/assets/javascripts/lib/utils/notify.js @@ -1,8 +1,7 @@ -/* eslint-disable no-var, consistent-return, no-return-assign */ +/* eslint-disable consistent-return, no-return-assign */ function notificationGranted(message, opts, onclick) { - var notification; - notification = new Notification(message, opts); + const notification = new Notification(message, opts); setTimeout( () => // Hide the notification after X amount of seconds @@ -21,8 +20,7 @@ function notifyPermissions() { } function notifyMe(message, body, icon, onclick) { - var opts; - opts = { + const opts = { body, icon, }; diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js index 0f2cc57b1f9..bc87232f40b 100644 --- a/app/assets/javascripts/lib/utils/number_utils.js +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -117,3 +117,36 @@ export const median = arr => { const sorted = arr.sort((a, b) => a - b); return arr.length % 2 !== 0 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; }; + +/** + * Computes the change from one value to the other as a percentage. + * @param {Number} firstY + * @param {Number} lastY + * @returns {Number} + */ +export const changeInPercent = (firstY, lastY) => { + if (firstY === lastY) { + return 0; + } + + return Math.round(((lastY - firstY) / Math.abs(firstY)) * 100); +}; + +/** + * Computes and formats the change from one value to the other as a percentage. + * Prepends the computed percentage with either "+" or "-" to indicate an in- or decrease and + * returns a given string if the result is not finite (for example, if the first value is "0"). + * @param firstY + * @param lastY + * @param nonFiniteResult + * @returns {String} + */ +export const formattedChangeInPercent = (firstY, lastY, { nonFiniteResult = '-' } = {}) => { + const change = changeInPercent(firstY, lastY); + + if (!Number.isFinite(change)) { + return nonFiniteResult; + } + + return `${change >= 0 ? '+' : ''}${change}%`; +}; diff --git a/app/assets/javascripts/lib/utils/text_utility.js b/app/assets/javascripts/lib/utils/text_utility.js index d13fbeb5fc7..0c194d67bce 100644 --- a/app/assets/javascripts/lib/utils/text_utility.js +++ b/app/assets/javascripts/lib/utils/text_utility.js @@ -36,25 +36,26 @@ export const humanize = string => export const dasherize = str => str.replace(/[_\s]+/g, '-'); /** - * Replaces whitespaces with hyphens, convert to lower case and remove non-allowed special characters - * @param {String} str + * Replaces whitespace and non-sluggish characters with a given separator + * @param {String} str - The string to slugify + * @param {String=} separator - The separator used to separate words (defaults to "-") * @returns {String} */ -export const slugify = str => { +export const slugify = (str, separator = '-') => { const slug = str .trim() .toLowerCase() - .replace(/[^a-zA-Z0-9_.-]+/g, '-'); + .replace(/[^a-zA-Z0-9_.-]+/g, separator); - return slug === '-' ? '' : slug; + return slug === separator ? '' : slug; }; /** - * Replaces whitespaces with underscore and converts to lower case + * Replaces whitespace and non-sluggish characters with underscores * @param {String} str * @returns {String} */ -export const slugifyWithUnderscore = str => str.toLowerCase().replace(/\s+/g, '_'); +export const slugifyWithUnderscore = str => slugify(str, '_'); /** * Truncates given text @@ -139,6 +140,14 @@ export const stripHtml = (string, replace = '') => { export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); /** + * Converts camelCase string to snake_case + * + * @param {*} string + */ +export const convertToSnakeCase = string => + slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' ')); + +/** * Converts a sentence to lower case from the second word onwards * e.g. Hello World => Hello world * diff --git a/app/assets/javascripts/lib/utils/tick_formats.js b/app/assets/javascripts/lib/utils/tick_formats.js deleted file mode 100644 index af3ca714400..00000000000 --- a/app/assets/javascripts/lib/utils/tick_formats.js +++ /dev/null @@ -1,39 +0,0 @@ -import { createDateTimeFormat } from '../../locale'; - -let dateTimeFormats; - -export const initDateFormats = () => { - const dayFormat = createDateTimeFormat({ month: 'short', day: 'numeric' }); - const monthFormat = createDateTimeFormat({ month: 'long' }); - const yearFormat = createDateTimeFormat({ year: 'numeric' }); - - dateTimeFormats = { - dayFormat, - monthFormat, - yearFormat, - }; -}; - -initDateFormats(); - -/** - Formats a localized date in way that it can be used for d3.js axis.tickFormat(). - - That is, it displays - - 4-digit for first of January - - full month name for first of every month - - day and abbreviated month otherwise - - see also https://github.com/d3/d3-3.x-api-reference/blob/master/SVG-Axes.md#tickFormat - */ -export const dateTickFormat = date => { - if (date.getDate() !== 1) { - return dateTimeFormats.dayFormat.format(date); - } - - if (date.getMonth() > 0) { - return dateTimeFormats.monthFormat.format(date); - } - - return dateTimeFormats.yearFormat.format(date); -}; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index b6b96fe7bd5..dd868bb9f4c 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, no-param-reassign, consistent-return, one-var, no-else-return */ +/* eslint-disable func-names, no-underscore-dangle, no-param-reassign, consistent-return, no-else-return */ import $ from 'jquery'; @@ -82,13 +82,13 @@ LineHighlighter.prototype.highlightHash = function(newHash) { }; LineHighlighter.prototype.clickHandler = function(event) { - var current, lineNumber, range; + let range; event.preventDefault(); this.clearHighlight(); - lineNumber = $(event.target) + const lineNumber = $(event.target) .closest('a') .data('lineNumber'); - current = this.hashToRange(this._hash); + const current = this.hashToRange(this._hash); if (!(current[0] && event.shiftKey)) { // If there's no current selection, or there is but Shift wasn't held, // treat this like a single-line selection. @@ -121,12 +121,11 @@ LineHighlighter.prototype.clearHighlight = function() { // // Returns an Array LineHighlighter.prototype.hashToRange = function(hash) { - var first, last, matches; // ?L(\d+)(?:-(\d+))?$/) - matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); + const matches = hash.match(/^#?L(\d+)(?:-(\d+))?$/); if (matches && matches.length) { - first = parseInt(matches[1], 10); - last = matches[2] ? parseInt(matches[2], 10) : null; + const first = parseInt(matches[1], 10); + const last = matches[2] ? parseInt(matches[2], 10) : null; return [first, last]; } else { return [null, null]; @@ -160,7 +159,7 @@ LineHighlighter.prototype.highlightRange = function(range) { // Set the URL hash string LineHighlighter.prototype.setHash = function(firstLineNumber, lastLineNumber) { - var hash; + let hash; if (lastLineNumber) { hash = `#L${firstLineNumber}-${lastLineNumber}`; } else { diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index c19a845eb69..465c9a362ba 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -37,7 +37,6 @@ import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import { initUserTracking } from './tracking'; import { __ } from './locale'; -import initPrivacyPolicyUpdateCallout from './privacy_policy_update_callout'; import 'ee_else_ce/main_ee'; @@ -97,7 +96,6 @@ function deferredInitialisation() { initUsagePingConsent(); initUserPopovers(); initUserTracking(); - initPrivacyPolicyUpdateCallout(); if (document.querySelector('.search')) initSearchAutocomplete(); @@ -162,24 +160,6 @@ function deferredInitialisation() { }); loadAwardsHandler(); - - /** - * Toggle Canary Badge - * - * For GitLab.com only, when the user is using canary - * we render a Next badge and hide the option to switch - * to canay - */ - if (Cookies.get('gitlab_canary') && Cookies.get('gitlab_canary') === 'true') { - const canaryBadge = document.querySelector('.js-canary-badge'); - const canaryLink = document.querySelector('.js-canary-link'); - if (canaryBadge) { - canaryBadge.classList.remove('hidden'); - } - if (canaryLink) { - canaryLink.classList.add('hidden'); - } - } } document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index 29a0e5a904a..f93dbcd4c47 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -18,7 +18,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) => createFlash(s__("ManualOrdering|Couldn't save the order of the issues")); }); -const initManualOrdering = () => { +const initManualOrdering = (draggableSelector = 'li.issue') => { const issueList = document.querySelector('.manual-ordering'); if (!issueList || !(gon.current_user_id > 0)) { @@ -34,14 +34,14 @@ const initManualOrdering = () => { group: { name: 'issues', }, - draggable: 'li.issue', + draggable: draggableSelector, onStart: () => { sortableStart(); }, onUpdate: event => { const el = event.item; - const url = el.getAttribute('url'); + const url = el.getAttribute('url') || el.dataset.url; const prev = el.previousElementSibling; const next = el.nextElementSibling; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 7223b5c0d43..3a7ade5ad94 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, no-underscore-dangle, one-var, consistent-return */ +/* eslint-disable func-names, no-underscore-dangle, consistent-return */ import $ from 'jquery'; import { __ } from '~/locale'; @@ -17,14 +17,7 @@ function MergeRequest(opts) { this.opts = opts != null ? opts : {}; this.submitNoteForm = this.submitNoteForm.bind(this); this.$el = $('.merge-request'); - this.$('.show-all-commits').on( - 'click', - (function(_this) { - return function() { - return _this.showAllCommits(); - }; - })(this), - ); + this.$('.show-all-commits').on('click', () => this.showAllCommits()); this.initTabs(); this.initMRBtnListeners(); @@ -71,12 +64,10 @@ MergeRequest.prototype.showAllCommits = function() { }; MergeRequest.prototype.initMRBtnListeners = function() { - var _this; - _this = this; + const _this = this; return $('a.btn-close, a.btn-reopen').on('click', function(e) { - var $this, shouldSubmit; - $this = $(this); - shouldSubmit = $this.hasClass('btn-comment'); + const $this = $(this); + const shouldSubmit = $this.hasClass('btn-comment'); if (shouldSubmit && $this.data('submitted')) { return; } @@ -95,8 +86,7 @@ MergeRequest.prototype.initMRBtnListeners = function() { }; MergeRequest.prototype.submitNoteForm = function(form, $button) { - var noteText; - noteText = form.find('textarea.js-note-text').val(); + const noteText = form.find('textarea.js-note-text').val(); if (noteText.trim().length > 0) { form.submit(); $button.data('submitted', true); @@ -106,7 +96,7 @@ MergeRequest.prototype.submitNoteForm = function(form, $button) { MergeRequest.prototype.initCommitMessageListeners = function() { $(document).on('click', 'a.js-with-description-link', e => { - var textarea = $('textarea.js-commit-message'); + const textarea = $('textarea.js-commit-message'); e.preventDefault(); textarea.val(textarea.data('messageWithDescription')); @@ -115,7 +105,7 @@ MergeRequest.prototype.initCommitMessageListeners = function() { }); $(document).on('click', 'a.js-without-description-link', e => { - var textarea = $('textarea.js-commit-message'); + const textarea = $('textarea.js-commit-message'); e.preventDefault(); textarea.val(textarea.data('messageWithoutDescription')); diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue new file mode 100644 index 00000000000..8eeac737a11 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue @@ -0,0 +1,227 @@ +<script> +import { flatten, isNumber } from 'underscore'; +import { GlLineChart, 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 { graphDataValidatorForAnomalyValues } from '../../utils'; +import MonitorTimeSeriesChart from './time_series.vue'; + +/** + * Series indexes + */ +const METRIC = 0; +const UPPER = 1; +const LOWER = 2; + +/** + * Boundary area appearance + */ +const AREA_COLOR = colorValues.anomalyAreaColor; +const AREA_OPACITY = areaOpacityValues.default; +const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`; + +/** + * The anomaly component highlights when a metric shows + * some anomalous behavior. + * + * It shows both a metric line and a boundary band in a + * time series chart, the boundary band shows the normal + * range of values the metric should take. + * + * This component accepts 3 queries, which contain the + * "metric", "upper" limit and "lower" limit. + * + * The upper and lower series are "stacked areas" visually + * to create the boundary band, and if any "metric" value + * is outside this band, it is highlighted to warn users. + * + * The boundary band stack must be painted above the 0 line + * so the area is shown correctly. If any of the values of + * the data are negative, the chart data is shifted to be + * above 0 line. + * + * The data passed to the time series is will always be + * positive, but reformatted to show the original values of + * data. + * + */ +export default { + components: { + GlLineChart, + GlChartSeriesLabel, + MonitorTimeSeriesChart, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForAnomalyValues, + }, + }, + computed: { + series() { + return this.graphData.queries.map(query => { + const values = query.result[0] ? query.result[0].values : []; + return { + label: query.label, + data: values.filter(([, value]) => !Number.isNaN(value)), + }; + }); + }, + /** + * If any of the values of the data is negative, the + * chart data is shifted to the lowest value + * + * This offset is the lowest value. + */ + yOffset() { + const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y))); + const min = values.length ? Math.floor(Math.min(...values)) : 0; + return min < 0 ? -min : 0; + }, + metricData() { + const originalMetricQuery = this.graphData.queries[0]; + + const metricQuery = { ...originalMetricQuery }; + metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [ + x, + y + this.yOffset, + ]); + return { + ...this.graphData, + type: 'line-chart', + queries: [metricQuery], + }; + }, + metricSeriesConfig() { + return { + type: 'line', + symbol: 'circle', + symbolSize: (val, params) => { + if (this.isDatapointAnomaly(params.dataIndex)) { + return symbolSizes.anomaly; + } + // 0 causes echarts to throw an error, use small number instead + // see https://gitlab.com/gitlab-org/gitlab-ui/issues/423 + return 0.001; + }, + showSymbol: true, + itemStyle: { + color: params => { + if (this.isDatapointAnomaly(params.dataIndex)) { + return colorValues.anomalySymbol; + } + return colorValues.primaryColor; + }, + }, + }; + }, + chartOptions() { + const [, upperSeries, lowerSeries] = this.series; + const calcOffsetY = (data, offsetCallback) => + data.map((value, dataIndex) => { + const [x, y] = value; + return [x, y + offsetCallback(dataIndex)]; + }); + + const yAxisWithOffset = { + name: this.yAxisLabel, + axisLabel: { + formatter: num => roundOffFloat(num - this.yOffset, 3).toString(), + }, + }; + + /** + * Boundary is rendered by 2 series: An invisible + * series (opacity: 0) stacked on a visible one. + * + * Order is important, lower boundary is stacked + * *below* the upper boundary. + */ + const boundarySeries = []; + + if (upperSeries.data.length && lowerSeries.data.length) { + // Lower boundary, plus the offset if negative values + boundarySeries.push( + this.makeBoundarySeries({ + name: this.formatLegendLabel(lowerSeries), + data: calcOffsetY(lowerSeries.data, () => this.yOffset), + }), + ); + // Upper boundary, minus the lower boundary + boundarySeries.push( + this.makeBoundarySeries({ + name: this.formatLegendLabel(upperSeries), + data: calcOffsetY(upperSeries.data, i => -this.yValue(LOWER, i)), + areaStyle: { + color: AREA_COLOR, + opacity: AREA_OPACITY, + }, + }), + ); + } + return { yAxis: yAxisWithOffset, series: boundarySeries }; + }, + }, + methods: { + formatLegendLabel(query) { + return query.label; + }, + yValue(seriesIndex, dataIndex) { + const d = this.series[seriesIndex].data[dataIndex]; + return d && d[1]; + }, + yValueFormatted(seriesIndex, dataIndex) { + const y = this.yValue(seriesIndex, dataIndex); + return isNumber(y) ? y.toFixed(3) : ''; + }, + isDatapointAnomaly(dataIndex) { + const yVal = this.yValue(METRIC, dataIndex); + const yUpper = this.yValue(UPPER, dataIndex); + const yLower = this.yValue(LOWER, dataIndex); + return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower); + }, + makeBoundarySeries(series) { + const stackKey = 'anomaly-boundary-series-stack'; + return { + type: 'line', + stack: stackKey, + lineStyle: { + width: 0, + color: AREA_COLOR_RGBA, // legend color + }, + color: AREA_COLOR_RGBA, // tooltip color + symbol: 'none', + ...series, + }; + }, + }, +}; +</script> + +<template> + <monitor-time-series-chart + v-bind="$attrs" + :graph-data="metricData" + :option="chartOptions" + :series-config="metricSeriesConfig" + > + <slot></slot> + <template v-slot:tooltipContent="slotProps"> + <div + v-for="(content, seriesIndex) in slotProps.tooltip.content" + :key="seriesIndex" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="content.color"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ yValueFormatted(seriesIndex, content.dataIndex) }} + </div> + </div> + </template> + </monitor-time-series-chart> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue new file mode 100644 index 00000000000..b8158247e49 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -0,0 +1,73 @@ +<script> +import { GlHeatmap } from '@gitlab/ui/dist/charts'; +import dateformat from 'dateformat'; +import PrometheusHeader from '../shared/prometheus_header.vue'; +import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; +import { graphDataValidatorForValues } from '../../utils'; + +export default { + components: { + GlHeatmap, + ResizableChartContainer, + PrometheusHeader, + }, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, false), + }, + containerWidth: { + type: Number, + required: true, + }, + }, + computed: { + chartData() { + return this.queries.result.reduce( + (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])], + [], + ); + }, + xAxisName() { + return this.graphData.x_label || ''; + }, + yAxisName() { + return this.graphData.y_label || ''; + }, + xAxisLabels() { + return this.queries.result.map(res => Object.values(res.metric)[0]); + }, + yAxisLabels() { + return this.result.values.map(val => { + const [yLabel] = val; + + return dateformat(new Date(yLabel), 'HH:MM:ss'); + }); + }, + result() { + return this.queries.result[0]; + }, + queries() { + return this.graphData.queries[0]; + }, + }, +}; +</script> +<template> + <div class="prometheus-graph col-12 col-lg-6"> + <prometheus-header :graph-title="graphData.title" /> + <resizable-chart-container> + <gl-heatmap + ref="heatmapChart" + v-bind="$attrs" + :data-series="chartData" + :x-axis-name="xAxisName" + :y-axis-name="yAxisName" + :x-axis-labels="xAxisLabels" + :y-axis-labels="yAxisLabels" + :width="containerWidth" + /> + </resizable-chart-container> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 78fe575717a..6a88c8a5ee3 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,17 +1,23 @@ <script> import { s__, __ } from '~/locale'; -import { GlLink, GlButton, GlTooltip } from '@gitlab/ui'; +import _ from 'underscore'; +import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; -import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; +import { roundOffFloat } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; -import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants'; +import { + chartHeight, + graphTypes, + lineTypes, + lineWidths, + symbolSizes, + dateFormats, +} from '../../constants'; import { makeDataSeries } from '~/helpers/monitor_helper'; import { graphDataValidatorForValues } from '../../utils'; -let debouncedResize; - export default { components: { GlAreaChart, @@ -22,6 +28,9 @@ export default { GlLink, Icon, }, + directives: { + GlResizeObserverDirective, + }, inheritAttrs: false, props: { graphData: { @@ -29,9 +38,15 @@ export default { required: true, validator: graphDataValidatorForValues.bind(null, false), }, - containerWidth: { - type: Number, - required: true, + option: { + type: Object, + required: false, + default: () => ({}), + }, + seriesConfig: { + type: Object, + required: false, + default: () => ({}), }, deploymentData: { type: Array, @@ -99,29 +114,35 @@ export default { const lineWidth = appearance && appearance.line && appearance.line.width ? appearance.line.width - : undefined; + : lineWidths.default; const areaStyle = { opacity: appearance && appearance.area && typeof appearance.area.opacity === 'number' ? appearance.area.opacity : undefined, }; - const series = makeDataSeries(query.result, { name: this.formatLegendLabel(query), lineStyle: { type: lineType, width: lineWidth, + color: this.primaryColor, }, showSymbol: false, areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, + ...this.seriesConfig, }); return acc.concat(series); }, []); }, + chartOptionSeries() { + return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []); + }, chartOptions() { + const option = _.omit(this.option, 'series'); return { + series: this.chartOptionSeries, xAxis: { name: __('Time'), type: 'time', @@ -138,8 +159,8 @@ export default { formatter: num => roundOffFloat(num, 3).toString(), }, }, - series: this.scatterSeries, dataZoom: [this.dataZoomConfig], + ...option, }; }, dataZoomConfig() { @@ -147,6 +168,14 @@ export default { return handleIcon ? { handleIcon } : {}; }, + /** + * This method returns the earliest time value in all series of a chart. + * Takes a chart data with data to populate a timeseries. + * data should be an array of data points [t, y] where t is a ISO formatted date, + * and is sorted by t (time). + * @returns {(String|null)} earliest x value from all series, or null when the + * chart series data is empty. + */ earliestDatapoint() { return this.chartData.reduce((acc, series) => { const { data } = series; @@ -206,21 +235,13 @@ export default { return `${this.graphData.y_label}`; }, }, - watch: { - containerWidth: 'onResize', - }, mounted() { const graphTitleEl = this.$refs.graphTitle; if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) { this.showTitleTooltip = true; } }, - beforeDestroy() { - window.removeEventListener('resize', debouncedResize); - }, created() { - debouncedResize = debounceByAnimationFrame(this.onResize); - window.addEventListener('resize', debouncedResize); this.setSvg('rocket'); this.setSvg('scroll-handle'); }, @@ -241,10 +262,11 @@ export default { this.tooltip.sha = deploy.sha.substring(0, 8); this.tooltip.commitUrl = deploy.commitUrl; } else { - const { seriesName, color } = dataPoint; + const { seriesName, color, dataIndex } = dataPoint; const value = yVal.toFixed(3); this.tooltip.content.push({ name: seriesName, + dataIndex, value, color, }); @@ -276,7 +298,7 @@ export default { </script> <template> - <div class="prometheus-graph"> + <div v-gl-resize-observer-directive="onResize" class="prometheus-graph"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" @@ -317,23 +339,27 @@ export default { </template> <template v-else> <template slot="tooltipTitle"> - <div class="text-nowrap"> - {{ tooltip.title }} - </div> + <slot name="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} + </div> + </slot> </template> <template slot="tooltipContent"> - <div - v-for="(content, key) in tooltip.content" - :key="key" - class="d-flex justify-content-between" - > - <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> - {{ content.name }} - </gl-chart-series-label> - <div class="prepend-left-32"> - {{ content.value }} + <slot name="tooltipContent" :tooltip="tooltip"> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> </div> - </div> + </slot> </template> </template> </component> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b4ea415bb51..26e2c2568c1 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -11,7 +11,7 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; @@ -22,12 +22,9 @@ import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import { sidebarAnimationDuration } from '../constants'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -let sidebarMutationObserver; - export default { components: { VueDraggable, @@ -167,10 +164,10 @@ export default { data() { return { state: 'gettingStarted', - elWidth: 0, formIsValid: null, selectedTimeWindow: {}, isRearrangingPanels: false, + hasValidDates: true, }; }, computed: { @@ -178,7 +175,7 @@ export default { return this.customMetricsAvailable && this.customMetricsPath.length; }, ...mapState('monitoringDashboard', [ - 'groups', + 'dashboard', 'emptyState', 'showEmptyState', 'environments', @@ -189,10 +186,15 @@ export default { 'additionalPanelTypesEnabled', ]), firstDashboard() { - return this.allDashboards[0] || {}; + return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 + ? this.allDashboards[0] + : {}; + }, + selectedDashboard() { + return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; }, selectedDashboardText() { - return this.currentDashboard || this.firstDashboard.display_name; + return this.selectedDashboard.display_name; }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; @@ -200,8 +202,13 @@ export default { addingMetricsAvailable() { return IS_EE && this.canAddMetrics && !this.showEmptyState; }, - alertWidgetAvailable() { - return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint; + hasHeaderButtons() { + return ( + this.addingMetricsAvailable || + this.showRearrangePanelsBtn || + this.selectedDashboard.can_edit || + this.externalDashboardUrl.length + ); }, }, created() { @@ -214,11 +221,6 @@ export default { projectPath: this.projectPath, }); }, - beforeDestroy() { - if (sidebarMutationObserver) { - sidebarMutationObserver.disconnect(); - } - }, mounted() { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); @@ -235,17 +237,12 @@ export default { this.selectedTimeWindow = range; if (!isValidDate(start) || !isValidDate(end)) { + this.hasValidDates = false; this.showInvalidDateError(); } else { + this.hasValidDates = true; this.fetchData(range); } - - sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); - sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); } }, methods: { @@ -253,43 +250,25 @@ export default { 'fetchData', 'setGettingStartedEmptyState', 'setEndpoints', - 'setDashboardEnabled', + 'setPanelGroupMetrics', ]), chartsWithData(charts) { - if (!this.useDashboardEndpoint) { - return charts; - } return charts.filter(chart => chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), ); }, - csvText(graphData) { - const chartData = graphData.queries[0].result[0].values; - const yLabel = graphData.y_label; - const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings - return chartData.reduce((csv, data) => { - const row = data.join(','); - return `${csv}${row}\r\n`; - }, header); - }, - downloadCsv(graphData) { - const data = new Blob([this.csvText(graphData)], { type: 'text/plain' }); - return window.URL.createObjectURL(data); - }, - // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed - // Issue number: https://gitlab.com/gitlab-org/gitlab-foss/issues/63845 - getGraphAlerts(queries) { - if (!this.allAlerts) return {}; - const metricIdsForChart = queries.map(q => q.metricId); - return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); - }, - getGraphAlertValues(queries) { - return Object.values(this.getGraphAlerts(queries)); - }, - showToast() { - this.$toast.show(__('Link copied')); - }, - // TODO: END + updateMetrics(key, metrics) { + this.setPanelGroupMetrics({ + metrics, + key, + }); + }, + removeMetric(key, metrics, graphIndex) { + this.setPanelGroupMetrics({ + metrics: metrics.filter((v, i) => i !== graphIndex), + key, + }); + }, removeGraph(metrics, graphIndex) { // At present graphs will not be removed, they should removed using the vuex store // See https://gitlab.com/gitlab-org/gitlab/issues/27835 @@ -306,11 +285,6 @@ export default { hideAddMetricModal() { this.$refs.addMetricModal.hide(); }, - onSidebarMutation() { - setTimeout(() => { - this.elWidth = this.$el.clientWidth; - }, sidebarAnimationDuration); - }, toggleRearrangingPanels() { this.isRearrangingPanels = !this.isRearrangingPanels; }, @@ -389,7 +363,7 @@ export default { </gl-form-group> <gl-form-group - v-if="!showEmptyState" + v-if="hasValidDates" :label="s__('Metrics|Show last')" label-size="sm" label-for="monitor-time-window-dropdown" @@ -403,7 +377,7 @@ export default { </template> <gl-form-group - v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length" + 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" > @@ -451,6 +425,14 @@ export default { </gl-modal> <gl-button + v-if="selectedDashboard.can_edit" + class="mt-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + > + {{ __('Edit dashboard') }} + </gl-button> + + <gl-button v-if="externalDashboardUrl.length" class="mt-1 js-external-dashboard-link" variant="primary" @@ -468,116 +450,46 @@ export default { <div v-if="!showEmptyState"> <graph-group - v-for="(groupData, index) in groups" + v-for="(groupData, index) in dashboard.panel_groups" :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" :collapse-group="groupHasData(groupData)" > - <template v-if="additionalPanelTypesEnabled"> - <vue-draggable - :list="groupData.metrics" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" + <vue-draggable + :value="groupData.metrics" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updateMetrics(groupData.key, $event)" + > + <div + v-for="(graphData, graphIndex) in groupData.metrics" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" > - <div - v-for="(graphData, graphIndex) in groupData.metrics" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" - > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removeGraph(groupData.metrics, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" - ><icon name="close" - /></a> - </div> - - <panel-type - :clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - :graph-data="graphData" - :dashboard-width="elWidth" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removeGraph(groupData.metrics, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" + ><icon name="close" + /></a> </div> - </div> - </vue-draggable> - </template> - <template v-else> - <monitor-time-series-chart - v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" - :key="graphIndex" - class="col-12 col-lg-6 pb-3" - :graph-data="graphData" - :deployment-data="deploymentData" - :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="elWidth" - :project-path="projectPath" - group-id="monitor-time-series-chart" - > - <div - class="d-flex align-items-center" - :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'" - > - <alert-widget - v-if="alertWidgetAvailable && graphData" - :modal-id="`alert-modal-${index}-${graphIndex}`" + + <panel-type + :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" + :graph-data="graphData" :alerts-endpoint="alertsEndpoint" - :relevant-queries="graphData.queries" - :alerts-to-manage="getGraphAlerts(graphData.queries)" - @setAlerts="setAlerts" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" /> - <gl-dropdown - v-gl-tooltip - class="ml-2 mr-3" - toggle-class="btn btn-transparent border-0" - :right="true" - :no-caret="true" - :title="__('More actions')" - > - <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> - </template> - <gl-dropdown-item - v-track-event="downloadCSVOptions(graphData.title)" - :href="downloadCsv(graphData)" - download="chart_metrics.csv" - > - {{ __('Download CSV') }} - </gl-dropdown-item> - <gl-dropdown-item - v-track-event=" - generateLinkToChartOptions( - generateLink(groupData.group, graphData.title, graphData.y_label), - ) - " - class="js-chart-link" - :data-clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - @click="showToast" - > - {{ __('Generate link to chart') }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${index}-${graphIndex}`" - > - {{ __('Alerts') }} - </gl-dropdown-item> - </gl-dropdown> </div> - </monitor-time-series-chart> - </template> + </div> + </vue-draggable> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue index 4616a767295..8749019c5cd 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue @@ -55,17 +55,13 @@ export default { }; }, }, + watch: { + selectedTimeWindow() { + this.verifyTimeRange(); + }, + }, mounted() { - const range = getTimeWindow(this.selectedTimeWindow); - if (range) { - this.selectedTimeWindowText = this.timeWindows[range]; - } else { - this.customTime = { - from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), - to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), - }; - this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); - } + this.verifyTimeRange(); }, methods: { activeTimeWindow(key) { @@ -87,6 +83,18 @@ export default { closeDropdown() { this.$refs.dropdown.hide(); }, + verifyTimeRange() { + const range = getTimeWindow(this.selectedTimeWindow); + if (range) { + this.selectedTimeWindowText = this.timeWindows[range]; + } else { + this.customTime = { + from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), + to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), + }; + this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); + } + }, }, }; </script> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index 7857aaa6ecc..f75839c7c6b 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -35,9 +35,9 @@ export default { }; }, computed: { - ...mapState('monitoringDashboard', ['groups', 'metricsWithData']), + ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']), charts() { - const groupWithMetrics = this.groups.find(group => + const groupWithMetrics = this.dashboard.panel_groups.find(group => group.metrics.find(chart => this.chartHasData(chart)), ) || { metrics: [] }; @@ -78,9 +78,6 @@ export default { }, sidebarAnimationDuration); }, setInitialState() { - this.setFeatureFlags({ - prometheusEndpointEnabled: true, - }); this.setEndpoints({ dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl), }); diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index ee3a2bae79b..3cb6ccb64b1 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -45,7 +45,7 @@ export default { <div v-if="showPanels" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> - <a role="button" @click="collapse"> + <a role="button" class="js-graph-group-toggle" @click="collapse"> <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" /> </a> </div> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 1a14d06f4c8..cafb4b0b479 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -11,7 +11,9 @@ import { } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; +import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; +import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; @@ -19,7 +21,7 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; export default { components: { MonitorSingleStatChart, - MonitorTimeSeriesChart, + MonitorHeatmapChart, MonitorEmptyChart, Icon, GlDropdown, @@ -40,10 +42,6 @@ export default { type: Object, required: true, }, - dashboardWidth: { - type: Number, - required: true, - }, index: { type: String, required: false, @@ -71,6 +69,12 @@ export default { const data = new Blob([this.csvText], { type: 'text/plain' }); return window.URL.createObjectURL(data); }, + monitorChartComponent() { + if (this.isPanelType('anomaly-chart')) { + return MonitorAnomalyChart; + } + return MonitorTimeSeriesChart; + }, }, methods: { getGraphAlerts(queries) { @@ -97,14 +101,19 @@ export default { v-if="isPanelType('single-stat') && graphDataHasMetrics" :graph-data="graphData" /> - <monitor-time-series-chart + <monitor-heatmap-chart + v-else-if="isPanelType('heatmap') && graphDataHasMetrics" + :graph-data="graphData" + :container-width="dashboardWidth" + /> + <component + :is="monitorChartComponent" v-else-if="graphDataHasMetrics" :graph-data="graphData" :deployment-data="deploymentData" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="dashboardWidth" - group-id="monitor-area-chart" + group-id="panel-type-chart" > <div class="d-flex align-items-center"> <alert-widget @@ -146,6 +155,6 @@ export default { </gl-dropdown-item> </gl-dropdown> </div> - </monitor-time-series-chart> + </component> <monitor-empty-chart v-else :graph-title="graphData.title" /> </template> diff --git a/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue new file mode 100644 index 00000000000..153c8f389db --- /dev/null +++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue @@ -0,0 +1,15 @@ +<script> +export default { + props: { + graphTitle: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 2836fe4fc26..1a1fcdd0e66 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -14,13 +14,28 @@ export const graphTypes = { }; export const symbolSizes = { + anomaly: 8, default: 14, }; +export const areaOpacityValues = { + default: 0.2, +}; + +export const colorValues = { + primaryColor: '#1f78d1', // $blue-500 (see variables.scss) + anomalySymbol: '#db3b21', + anomalyAreaColor: '#1f78d1', +}; + export const lineTypes = { default: 'solid', }; +export const lineWidths = { + default: 2, +}; + export const timeWindows = { thirtyMinutes: __('30 minutes'), threeHours: __('3 hours'), diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 6aa1fb5e9c6..a14145d480b 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -11,13 +11,6 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - if (gon.features) { - store.dispatch('monitoringDashboard/setFeatureFlags', { - prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, - additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes, - }); - } - const [currentDashboard] = getParameterValues('dashboard'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 2cf34ddb45b..6a8e3cc82f5 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -7,7 +7,7 @@ import { s__, __ } from '../../locale'; const MAX_REQUESTS = 3; -function backOffRequest(makeRequestCallback) { +export function backOffRequest(makeRequestCallback) { let requestCounter = 0; return backOff((next, stop) => { makeRequestCallback() @@ -35,14 +35,6 @@ export const setEndpoints = ({ commit }, endpoints) => { commit(types.SET_ENDPOINTS, endpoints); }; -export const setFeatureFlags = ( - { commit }, - { prometheusEndpointEnabled, additionalPanelTypesEnabled }, -) => { - commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); - commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); -}; - export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; @@ -79,29 +71,7 @@ export const fetchData = ({ dispatch }, params) => { dispatch('fetchEnvironmentsData'); }; -export const fetchMetricsData = ({ state, dispatch }, params) => { - if (state.useDashboardEndpoint) { - return dispatch('fetchDashboard', params); - } - - dispatch('requestMetricsData'); - - return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) - .then(resp => resp.data) - .then(response => { - if (!response || !response.data || !response.success) { - dispatch('receiveMetricsDataFailure', null); - createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); - } - dispatch('receiveMetricsDataSuccess', response.data); - }) - .catch(error => { - dispatch('receiveMetricsDataFailure', error); - if (state.setShowErrorBanner) { - createFlash(s__('Metrics|There was an error while retrieving metrics')); - } - }); -}; +export const fetchMetricsData = ({ dispatch }, params) => dispatch('fetchDashboard', params); export const fetchDashboard = ({ state, dispatch }, params) => { dispatch('requestMetricsDashboard'); @@ -111,11 +81,13 @@ export const fetchDashboard = ({ state, dispatch }, params) => { params.dashboard = state.currentDashboard; } - return axios - .get(state.dashboardEndpoint, { params }) + return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) .then(response => { - dispatch('receiveMetricsDashboardSuccess', { response, params }); + dispatch('receiveMetricsDashboardSuccess', { + response, + params, + }); }) .catch(error => { dispatch('receiveMetricsDashboardFailure', error); @@ -166,7 +138,7 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { commit(types.REQUEST_METRICS_DATA); const promises = []; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.panels.forEach(panel => { panel.metrics.forEach(metric => { promises.push(dispatch('fetchPrometheusMetric', { metric, params })); @@ -221,5 +193,15 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { }); }; +/** + * Set a new array of metrics to a panel group + * @param {*} data An object containing + * - `key` with a unique panel key + * - `metrics` with the metrics array + */ +export const setPanelGroupMetrics = ({ commit }, data) => { + commit(types.SET_PANEL_GROUP_METRICS, data); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 9c546427c6e..fa15a2ba800 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -9,10 +9,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; -export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; -export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; +export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 320b33d3d69..696af5aed75 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,6 +1,7 @@ import Vue from 'vue'; +import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; -import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils'; +import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils'; const normalizePanel = panel => panel.metrics.map(normalizeMetric); @@ -10,10 +11,12 @@ export default { state.showEmptyState = true; }, [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { - state.groups = groupData.map(group => { + state.dashboard.panel_groups = groupData.map((group, i) => { + const key = `${slugify(group.group || 'default')}-${i}`; let { metrics = [], panels = [] } = group; // each panel has metric information that needs to be normalized + panels = panels.map(panel => ({ ...panel, metrics: normalizePanel(panel), @@ -22,24 +25,21 @@ export default { // for backwards compatibility, and to limit Vue template changes: // for each group alias panels to metrics // for each panel alias metrics to queries - if (state.useDashboardEndpoint) { - metrics = panels.map(panel => ({ - ...panel, - queries: panel.metrics, - })); - } + metrics = panels.map(panel => ({ + ...panel, + queries: panel.metrics, + })); return { ...group, panels, - metrics: normalizeMetrics(sortMetrics(metrics)), + key, + metrics: normalizeMetrics(metrics), }; }); - if (!state.groups.length) { + if (!state.dashboard.panel_groups.length) { state.emptyState = 'noData'; - } else { - state.showEmptyState = false; } }, [types.RECEIVE_METRICS_DATA_FAILURE](state, error) { @@ -65,7 +65,7 @@ export default { state.showEmptyState = false; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.metrics.forEach(metric => { metric.queries.forEach(query => { if (query.metric_id === metricId) { @@ -86,9 +86,6 @@ export default { state.currentDashboard = endpoints.currentDashboard; state.projectPath = endpoints.projectPath; }, - [types.SET_DASHBOARD_ENABLED](state, enabled) { - state.useDashboardEndpoint = enabled; - }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; }, @@ -97,12 +94,13 @@ export default { state.emptyState = 'noData'; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { - state.allDashboards = dashboards; - }, - [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) { - state.additionalPanelTypesEnabled = enabled; + state.allDashboards = dashboards || []; }, [types.SET_SHOW_ERROR_BANNER](state, enabled) { state.showErrorBanner = enabled; }, + [types.SET_PANEL_GROUP_METRICS](state, payload) { + const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key); + panelGroup.metrics = payload.metrics; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e894e988f6a..87e94311176 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -7,12 +7,12 @@ export default () => ({ environmentsEndpoint: null, deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, - useDashboardEndpoint: false, - additionalPanelTypesEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, - groups: [], + dashboard: { + panel_groups: [], + }, deploymentData: [], environments: [], metricsWithData: [], diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a19829f0c65..8a396b15a31 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -82,12 +82,6 @@ export const normalizeMetric = (metric = {}) => 'id', ); -export const sortMetrics = metrics => - _.chain(metrics) - .sortBy('title') - .sortBy('weight') - .value(); - export const normalizeQueryResult = timeSeries => { let normalizedResult = {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 00f188c1d5a..2ae1647011d 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,7 +1,6 @@ import dateformat from 'dateformat'; import { secondsIn, dateTimePickerRegex, dateFormats } from './constants'; - -const secondsToMilliseconds = seconds => seconds * 1000; +import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; export const getTimeDiff = timeWindow => { const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds @@ -131,4 +130,20 @@ export const downloadCSVOptions = title => { return { category, action, label: 'Chart title', property: title }; }; +/** + * This function validates the graph data contains exactly 3 queries plus + * value validations from graphDataValidatorForValues. + * @param {Object} isValues + * @param {Object} graphData the graph data response from a prometheus request + * @returns {boolean} true if the data is valid + */ +export const graphDataValidatorForAnomalyValues = graphData => { + const anomalySeriesCount = 3; // metric, upper, lower + return ( + graphData.queries && + graphData.queries.length === anomalySeriesCount && + graphDataValidatorForValues(false, graphData) + ); +}; + export default {}; diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index a0ba2193d90..c301c304409 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -1,12 +1,12 @@ -/* eslint-disable func-names, no-var, one-var, no-loop-func, consistent-return, camelcase */ +/* eslint-disable func-names, consistent-return, camelcase */ import $ from 'jquery'; import { __ } from '../locale'; import axios from '../lib/utils/axios_utils'; import Raphael from './raphael'; -export default (function() { - function BranchGraph(element1, options1) { +export default class BranchGraph { + constructor(element1, options1) { this.element = element1; this.options = options1; this.scrollTop = this.scrollTop.bind(this); @@ -28,7 +28,7 @@ export default (function() { this.load(); } - BranchGraph.prototype.load = function() { + load() { axios .get(this.options.url) .then(({ data }) => { @@ -37,21 +37,23 @@ export default (function() { this.buildGraph(); }) .catch(() => __('Error fetching network graph.')); - }; + } - BranchGraph.prototype.prepareData = function(days, commits) { - var c, ch, cw, j, len, ref; + prepareData(days, commits) { + let c = 0; + let j = 0; + let len = 0; this.days = days; this.commits = commits; this.collectParents(); this.graphHeight = $(this.element).height(); this.graphWidth = $(this.element).width(); - ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); - cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); + const ch = Math.max(this.graphHeight, this.offsetY + this.unitTime * this.mtime + 150); + const cw = Math.max(this.graphWidth, this.offsetX + this.unitSpace * this.mspace + 300); this.r = Raphael(this.element.get(0), cw, ch); this.top = this.r.set(); this.barHeight = Math.max(this.graphHeight, this.unitTime * this.days.length + 320); - ref = this.commits; + const ref = this.commits; for (j = 0, len = ref.length; j < len; j += 1) { c = ref[j]; if (c.id in this.parents) { @@ -61,37 +63,34 @@ export default (function() { this.markCommit(c); } return this.collectColors(); - }; + } - BranchGraph.prototype.collectParents = function() { - var c, j, len, p, ref, results; - ref = this.commits; - results = []; + collectParents() { + let j = 0; + let l = 0; + let len = 0; + let len1 = 0; + const ref = this.commits; + const results = []; for (j = 0, len = ref.length; j < len; j += 1) { - c = ref[j]; + const c = ref[j]; this.mtime = Math.max(this.mtime, c.time); this.mspace = Math.max(this.mspace, c.space); - results.push( - function() { - var l, len1, ref1, results1; - ref1 = c.parents; - results1 = []; - for (l = 0, len1 = ref1.length; l < len1; l += 1) { - p = ref1[l]; - this.parents[p[0]] = true; - results1.push((this.mspace = Math.max(this.mspace, p[1]))); - } - return results1; - }.call(this), - ); + const ref1 = c.parents; + const results1 = []; + for (l = 0, len1 = ref1.length; l < len1; l += 1) { + const p = ref1[l]; + this.parents[p[0]] = true; + results1.push((this.mspace = Math.max(this.mspace, p[1]))); + } + results.push(results1); } return results; - }; + } - BranchGraph.prototype.collectColors = function() { - var k, results; - k = 0; - results = []; + collectColors() { + let k = 0; + const results = []; while (k < this.mspace) { this.colors.push(Raphael.getColor(0.8)); // Skipping a few colors in the spectrum to get more contrast between colors @@ -100,23 +99,24 @@ export default (function() { results.push((k += 1)); } return results; - }; + } - BranchGraph.prototype.buildGraph = function() { - var cuday, cumonth, day, len, mm, ref; + buildGraph() { + let mm = 0; + let len = 0; + let cuday = 0; + let cumonth = ''; const { r } = this; - cuday = 0; - cumonth = ''; r.rect(0, 0, 40, this.barHeight).attr({ fill: '#222', }); r.rect(40, 0, 30, this.barHeight).attr({ fill: '#444', }); - ref = this.days; + const ref = this.days; for (mm = 0, len = ref.length; mm < len; mm += 1) { - day = ref[mm]; + const day = ref[mm]; if (cuday !== day[0] || cumonth !== day[1]) { // Dates r.text(55, this.offsetY + this.unitTime * mm, day[0]).attr({ @@ -138,29 +138,28 @@ export default (function() { } this.renderPartialGraph(); return this.bindEvents(); - }; + } - BranchGraph.prototype.renderPartialGraph = function() { - var commit, end, i, isGraphEdge, start, x, y; - start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10; + renderPartialGraph() { + const isGraphEdge = true; + let i = 0; + let start = Math.floor((this.element.scrollTop() - this.offsetY) / this.unitTime) - 10; if (start < 0) { - isGraphEdge = true; start = 0; } - end = start + 40; + let end = start + 40; if (this.commits.length < end) { - isGraphEdge = true; end = this.commits.length; } if (this.prev_start === -1 || Math.abs(this.prev_start - start) > 10 || isGraphEdge) { i = start; this.prev_start = start; while (i < end) { - commit = this.commits[i]; + const commit = this.commits[i]; i += 1; if (commit.hasDrawn !== true) { - x = this.offsetX + this.unitSpace * (this.mspace - commit.space); - y = this.offsetY + this.unitTime * commit.time; + const x = this.offsetX + this.unitSpace * (this.mspace - commit.space); + const y = this.offsetY + this.unitTime * commit.time; this.drawDot(x, y, commit); this.drawLines(x, y, commit); this.appendLabel(x, y, commit); @@ -170,70 +169,62 @@ export default (function() { } return this.top.toFront(); } - }; + } - BranchGraph.prototype.bindEvents = function() { + bindEvents() { const { element } = this; - return $(element).scroll( - (function(_this) { - return function() { - return _this.renderPartialGraph(); - }; - })(this), - ); - }; + return $(element).scroll(() => this.renderPartialGraph()); + } - BranchGraph.prototype.scrollDown = function() { + scrollDown() { this.element.scrollTop(this.element.scrollTop() + 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollUp = function() { + scrollUp() { this.element.scrollTop(this.element.scrollTop() - 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollLeft = function() { + scrollLeft() { this.element.scrollLeft(this.element.scrollLeft() - 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollRight = function() { + scrollRight() { this.element.scrollLeft(this.element.scrollLeft() + 50); return this.renderPartialGraph(); - }; + } - BranchGraph.prototype.scrollBottom = function() { + scrollBottom() { return this.element.scrollTop(this.element.find('svg').height()); - }; + } - BranchGraph.prototype.scrollTop = function() { + scrollTop() { return this.element.scrollTop(0); - }; - - BranchGraph.prototype.appendLabel = function(x, y, commit) { - var label, rect, shortrefs, text, textbox; + } + appendLabel(x, y, commit) { if (!commit.refs) { return; } const { r } = this; - shortrefs = commit.refs; + let shortrefs = commit.refs; // Truncate if longer than 15 chars if (shortrefs.length > 17) { shortrefs = `${shortrefs.substr(0, 15)}…`; } - text = r.text(x + 4, y, shortrefs).attr({ + const text = r.text(x + 4, y, shortrefs).attr({ 'text-anchor': 'start', font: '10px Monaco, monospace', fill: '#FFF', title: commit.refs, }); - textbox = text.getBBox(); + const textbox = text.getBBox(); // Create rectangle based on the size of the textbox - rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ + const rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr({ fill: '#000', 'fill-opacity': 0.5, stroke: 'none', @@ -244,13 +235,13 @@ export default (function() { 'fill-opacity': 0.5, stroke: 'none', }); - label = r.set(rect, text); + const label = r.set(rect, text); label.transform(['t', -rect.getBBox().width - 15, 0]); // Set text to front return text.toFront(); - }; + } - BranchGraph.prototype.appendAnchor = function(x, y, commit) { + appendAnchor(x, y, commit) { const { r, top, options } = this; const anchor = r .circle(x, y, 10) @@ -270,9 +261,9 @@ export default (function() { }, ); return top.push(anchor); - }; + } - BranchGraph.prototype.drawDot = function(x, y, commit) { + drawDot(x, y, commit) { const { r } = this; r.circle(x, y, 3).attr({ fill: this.colors[commit.space], @@ -293,20 +284,24 @@ export default (function() { 'text-anchor': 'start', font: '14px Monaco, monospace', }); - }; + } - BranchGraph.prototype.drawLines = function(x, y, commit) { - var arrow, color, i, len, offset, parent, parentCommit, parentX1, parentX2, parentY, route; + drawLines(x, y, commit) { + let i = 0; + let len = 0; + let arrow = ''; + let offset = []; + let color = []; const { r } = this; const ref = commit.parents; const results = []; for (i = 0, len = ref.length; i < len; i += 1) { - parent = ref[i]; - parentCommit = this.preparedCommits[parent[0]]; - parentY = this.offsetY + this.unitTime * parentCommit.time; - parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); - parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); + const parent = ref[i]; + const parentCommit = this.preparedCommits[parent[0]]; + const parentY = this.offsetY + this.unitTime * parentCommit.time; + const parentX1 = this.offsetX + this.unitSpace * (this.mspace - parentCommit.space); + const parentX2 = this.offsetX + this.unitSpace * (this.mspace - parent[1]); // Set line color if (parentCommit.space <= commit.space) { color = this.colors[commit.space]; @@ -325,7 +320,7 @@ export default (function() { arrow = 'l-5,0,2,4,3,-4,-4,2'; } // Start point - route = ['M', x + offset[0], y + offset[1]]; + const route = ['M', x + offset[0], y + offset[1]]; // Add arrow if not first parent if (i > 0) { route.push(arrow); @@ -344,9 +339,9 @@ export default (function() { ); } return results; - }; + } - BranchGraph.prototype.markCommit = function(commit) { + markCommit(commit) { if (commit.id === this.options.commit_id) { const { r } = this; const x = this.offsetX + this.unitSpace * (this.mspace - commit.space); @@ -359,7 +354,5 @@ export default (function() { // Displayed in the center return this.element.scrollTop(y - this.graphHeight / 2); } - }; - - return BranchGraph; -})(); + } +} diff --git a/app/assets/javascripts/new_branch_form.js b/app/assets/javascripts/new_branch_form.js index 9f9db21d65b..918c6e408a2 100644 --- a/app/assets/javascripts/new_branch_form.js +++ b/app/assets/javascripts/new_branch_form.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, consistent-return, no-return-assign, no-shadow, no-else-return, @gitlab/i18n/no-non-i18n-strings */ +/* eslint-disable func-names, consistent-return, no-return-assign, no-else-return, @gitlab/i18n/no-non-i18n-strings */ import $ from 'jquery'; import RefSelectDropdown from './ref_select_dropdown'; @@ -26,23 +26,22 @@ export default class NewBranchForm { } setupRestrictions() { - var endsWith, invalid, single, startsWith; - startsWith = { + const startsWith = { pattern: /^(\/|\.)/g, prefix: "can't start with", conjunction: 'or', }; - endsWith = { + const endsWith = { pattern: /(\/|\.|\.lock)$/g, prefix: "can't end in", conjunction: 'or', }; - invalid = { + const invalid = { pattern: /(\s|~|\^|:|\?|\*|\[|\\|\.\.|@\{|\/{2,}){1}/g, prefix: "can't contain", conjunction: ', ', }; - single = { + const single = { pattern: /^@+$/g, prefix: "can't be", conjunction: 'or', @@ -51,19 +50,17 @@ export default class NewBranchForm { } validate() { - var errorMessage, errors, formatter, unique, validator; const { indexOf } = []; this.branchNameError.empty(); - unique = function(values, value) { + const unique = function(values, value) { if (indexOf.call(values, value) === -1) { values.push(value); } return values; }; - formatter = function(values, restriction) { - var formatted; - formatted = values.map(value => { + const formatter = function(values, restriction) { + const formatted = values.map(value => { switch (false) { case !/\s/.test(value): return 'spaces'; @@ -75,20 +72,17 @@ export default class NewBranchForm { }); return `${restriction.prefix} ${formatted.join(restriction.conjunction)}`; }; - validator = (function(_this) { - return function(errors, restriction) { - var matched; - matched = _this.name.val().match(restriction.pattern); - if (matched) { - return errors.concat(formatter(matched.reduce(unique, []), restriction)); - } else { - return errors; - } - }; - })(this); - errors = this.restrictions.reduce(validator, []); + const validator = (errors, restriction) => { + const matched = this.name.val().match(restriction.pattern); + if (matched) { + return errors.concat(formatter(matched.reduce(unique, []), restriction)); + } else { + return errors; + } + }; + const errors = this.restrictions.reduce(validator, []); if (errors.length > 0) { - errorMessage = $('<span/>').text(errors.join(', ')); + const errorMessage = $('<span/>').text(errors.join(', ')); return this.branchNameError.append(errorMessage); } } diff --git a/app/assets/javascripts/new_commit_form.js b/app/assets/javascripts/new_commit_form.js index b142f212eb0..037be8467cb 100644 --- a/app/assets/javascripts/new_commit_form.js +++ b/app/assets/javascripts/new_commit_form.js @@ -1,4 +1,4 @@ -/* eslint-disable no-var, no-return-assign */ +/* eslint-disable no-return-assign */ export default class NewCommitForm { constructor(form) { this.form = form; @@ -11,8 +11,7 @@ export default class NewCommitForm { this.renderDestination(); } renderDestination() { - var different; - different = this.branchName.val() !== this.originalBranch.val(); + const different = this.branchName.val() !== this.originalBranch.val(); if (different) { this.createMergeRequestContainer.show(); if (!this.wasDifferent) { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index 3715a91d599..defa278c089 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -1,8 +1,8 @@ -/* eslint-disable no-restricted-properties, func-names, no-var, camelcase, +/* eslint-disable no-restricted-properties, no-var, camelcase, no-unused-expressions, one-var, default-case, -consistent-return, no-alert, no-return-assign, -no-param-reassign, no-else-return, vars-on-top, -no-shadow, no-useless-escape, class-methods-use-this */ +consistent-return, no-alert, no-param-reassign, no-else-return, +vars-on-top, no-shadow, no-useless-escape, +class-methods-use-this */ /* global ResolveService */ @@ -281,14 +281,7 @@ export default class Notes { if (Notes.interval) { clearInterval(Notes.interval); } - return (Notes.interval = setInterval( - (function(_this) { - return function() { - return _this.refresh(); - }; - })(this), - this.pollingInterval, - )); + Notes.interval = setInterval(() => this.refresh(), this.pollingInterval); } refresh() { @@ -847,57 +840,52 @@ export default class Notes { var noteElId, $note; $note = $(e.currentTarget).closest('.note'); noteElId = $note.attr('id'); - $(`.note[id="${noteElId}"]`).each( - (function() { - // A same note appears in the "Discussion" and in the "Changes" tab, we have - // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, - // where $('#noteId') would return only one. - return function(i, el) { - var $note, $notes; - $note = $(el); - $notes = $note.closest('.discussion-notes'); - const discussionId = $('.notes', $notes).data('discussionId'); - - if (typeof gl.diffNotesCompileComponents !== 'undefined') { - if (gl.diffNoteApps[noteElId]) { - gl.diffNoteApps[noteElId].$destroy(); - } - } - - $note.remove(); + $(`.note[id="${noteElId}"]`).each((i, el) => { + // A same note appears in the "Discussion" and in the "Changes" tab, we have + // to remove all. Using $('.note[id='noteId']') ensure we get all the notes, + // where $('#noteId') would return only one. + const $note = $(el); + const $notes = $note.closest('.discussion-notes'); + const discussionId = $('.notes', $notes).data('discussionId'); + + if (typeof gl.diffNotesCompileComponents !== 'undefined') { + if (gl.diffNoteApps[noteElId]) { + gl.diffNoteApps[noteElId].$destroy(); + } + } - // check if this is the last note for this line - if ($notes.find('.note').length === 0) { - var notesTr = $notes.closest('tr'); + $note.remove(); - // "Discussions" tab - $notes.closest('.timeline-entry').remove(); + // check if this is the last note for this line + if ($notes.find('.note').length === 0) { + const notesTr = $notes.closest('tr'); - $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); + // "Discussions" tab + $notes.closest('.timeline-entry').remove(); - // The notes tr can contain multiple lists of notes, like on the parallel diff - // notesTr does not exist for image diffs - if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { - const $diffFile = $notes.closest('.diff-file'); - if ($diffFile.length > 0) { - const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { - detail: { - // badgeNumber's start with 1 and index starts with 0 - badgeNumber: $notes.index() + 1, - }, - }); + $(`.js-diff-avatars-${discussionId}`).trigger('remove.vue'); - $diffFile[0].dispatchEvent(removeBadgeEvent); - } + // The notes tr can contain multiple lists of notes, like on the parallel diff + // notesTr does not exist for image diffs + if (notesTr.find('.discussion-notes').length > 1 || notesTr.length === 0) { + const $diffFile = $notes.closest('.diff-file'); + if ($diffFile.length > 0) { + const removeBadgeEvent = new CustomEvent('removeBadge.imageDiff', { + detail: { + // badgeNumber's start with 1 and index starts with 0 + badgeNumber: $notes.index() + 1, + }, + }); - $notes.remove(); - } else if (notesTr.length > 0) { - notesTr.remove(); - } + $diffFile[0].dispatchEvent(removeBadgeEvent); } - }; - })(this), - ); + + $notes.remove(); + } else if (notesTr.length > 0) { + notesTr.remove(); + } + } + }); Notes.checkMergeRequestStatus(); return this.updateNotesCount(-1); diff --git a/app/assets/javascripts/notes/components/diff_discussion_header.vue b/app/assets/javascripts/notes/components/diff_discussion_header.vue new file mode 100644 index 00000000000..4c9075912ee --- /dev/null +++ b/app/assets/javascripts/notes/components/diff_discussion_header.vue @@ -0,0 +1,133 @@ +<script> +import { mapActions } from 'vuex'; +import _ from 'underscore'; + +import { s__, __, sprintf } from '~/locale'; +import { truncateSha } from '~/lib/utils/text_utility'; + +import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; +import noteEditedText from './note_edited_text.vue'; +import noteHeader from './note_header.vue'; + +export default { + name: 'DiffDiscussionHeader', + components: { + userAvatarLink, + noteEditedText, + noteHeader, + }, + props: { + discussion: { + type: Object, + required: true, + }, + }, + computed: { + notes() { + return this.discussion.notes; + }, + firstNote() { + return this.notes[0]; + }, + lastNote() { + return this.notes[this.notes.length - 1]; + }, + author() { + return this.firstNote.author; + }, + resolvedText() { + return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); + }, + lastUpdatedBy() { + return this.notes.length > 1 ? this.lastNote.author : null; + }, + lastUpdatedAt() { + return this.notes.length > 1 ? this.lastNote.created_at : null; + }, + headerText() { + const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; + const linkEnd = '</a>'; + + const { commit_id: commitId } = this.discussion; + let commitDisplay = commitId; + + if (commitId) { + commitDisplay = `<span class="commit-sha">${truncateSha(commitId)}</span>`; + } + + const { + for_commit: isForCommit, + diff_discussion: isDiffDiscussion, + active: isActive, + } = this.discussion; + + let text = s__('MergeRequests|started a thread'); + if (isForCommit) { + text = s__( + 'MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}', + ); + } else if (isDiffDiscussion && commitId) { + text = isActive + ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitDisplay}%{linkEnd}') + : s__( + 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitDisplay}%{linkEnd}', + ); + } else if (isDiffDiscussion) { + text = isActive + ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') + : s__( + 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', + ); + } + + return sprintf(text, { commitDisplay, linkStart, linkEnd }, false); + }, + }, + methods: { + ...mapActions(['toggleDiscussion']), + toggleDiscussionHandler() { + this.toggleDiscussion({ discussionId: this.discussion.id }); + }, + }, +}; +</script> + +<template> + <div class="discussion-header note-wrapper"> + <div v-once class="timeline-icon align-self-start flex-shrink-0"> + <user-avatar-link + v-if="author" + :link-href="author.path" + :img-src="author.avatar_url" + :img-alt="author.name" + :img-size="40" + /> + </div> + <div class="timeline-content w-100"> + <note-header + :author="author" + :created-at="firstNote.created_at" + :note-id="firstNote.id" + :include-toggle="true" + :expanded="discussion.expanded" + @toggleHandler="toggleDiscussionHandler" + > + <span v-html="headerText"></span> + </note-header> + <note-edited-text + v-if="discussion.resolved" + :edited-at="discussion.resolved_at" + :edited-by="discussion.resolved_by" + :action-text="resolvedText" + class-name="discussion-headline-light js-discussion-headline" + /> + <note-edited-text + v-else-if="lastUpdatedAt" + :edited-at="lastUpdatedAt" + :edited-by="lastUpdatedBy" + :action-text="__('Last updated')" + class-name="discussion-headline-light js-discussion-headline" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 3158e086f6c..e4f09492d9c 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -101,6 +101,7 @@ export default { <time-ago-tooltip :time="createdAt" tooltip-placement="bottom" /> </a> </template> + <slot name="extra-controls"></slot> <i class="fa fa-spinner fa-spin editing-spinner" :aria-label="__('Comment is being updated')" diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index cb1975a8962..47ec740b63a 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -1,18 +1,15 @@ <script> -import _ from 'underscore'; import { mapActions, mapGetters } from 'vuex'; import { GlTooltipDirective } from '@gitlab/ui'; -import { truncateSha } from '~/lib/utils/text_utility'; -import { s__, __, sprintf } from '~/locale'; +import { s__, __ } from '~/locale'; import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; import diffLineNoteFormMixin from 'ee_else_ce/notes/mixins/diff_line_note_form'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import Flash from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; -import noteHeader from './note_header.vue'; +import diffDiscussionHeader from './diff_discussion_header.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; -import noteEditedText from './note_edited_text.vue'; import noteForm from './note_form.vue'; import diffWithNote from './diff_with_note.vue'; import noteable from '../mixins/noteable'; @@ -27,9 +24,8 @@ export default { components: { icon, userAvatarLink, - noteHeader, + diffDiscussionHeader, noteSignedOutWidget, - noteEditedText, noteForm, DraftNote: () => import('ee_component/batch_comments/components/draft_note.vue'), TimelineEntryItem, @@ -92,9 +88,6 @@ export default { currentUser() { return this.getUserData; }, - author() { - return this.firstNote.author; - }, autosaveKey() { return getDiscussionReplyKey(this.firstNote.noteable_type, this.discussion.id); }, @@ -104,27 +97,6 @@ export default { firstNote() { return this.discussion.notes.slice(0, 1)[0]; }, - lastUpdatedBy() { - const { notes } = this.discussion; - - if (notes.length > 1) { - return notes[notes.length - 1].author; - } - - return null; - }, - lastUpdatedAt() { - const { notes } = this.discussion; - - if (notes.length > 1) { - return notes[notes.length - 1].created_at; - } - - return null; - }, - resolvedText() { - return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved'); - }, shouldShowJumpToNextDiscussion() { return this.showJumpToNextDiscussion(this.discussionsByDiffOrder ? 'diff' : 'discussion'); }, @@ -150,40 +122,6 @@ export default { shouldHideDiscussionBody() { return this.shouldRenderDiffs && !this.isExpanded; }, - actionText() { - const linkStart = `<a href="${_.escape(this.discussion.discussion_path)}">`; - const linkEnd = '</a>'; - - let { commit_id: commitId } = this.discussion; - if (commitId) { - commitId = `<span class="commit-sha">${truncateSha(commitId)}</span>`; - } - - const { - for_commit: isForCommit, - diff_discussion: isDiffDiscussion, - active: isActive, - } = this.discussion; - - let text = s__('MergeRequests|started a thread'); - if (isForCommit) { - text = s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}'); - } else if (isDiffDiscussion && commitId) { - text = isActive - ? s__('MergeRequests|started a thread on commit %{linkStart}%{commitId}%{linkEnd}') - : s__( - 'MergeRequests|started a thread on an outdated change in commit %{linkStart}%{commitId}%{linkEnd}', - ); - } else if (isDiffDiscussion) { - text = isActive - ? s__('MergeRequests|started a thread on %{linkStart}the diff%{linkEnd}') - : s__( - 'MergeRequests|started a thread on %{linkStart}an old version of the diff%{linkEnd}', - ); - } - - return sprintf(text, { commitId, linkStart, linkEnd }, false); - }, diffLine() { if (this.line) { return this.line; @@ -208,16 +146,11 @@ export default { methods: { ...mapActions([ 'saveNote', - 'toggleDiscussion', 'removePlaceholderNotes', 'toggleResolveNote', 'expandDiscussion', 'removeConvertedDiscussion', ]), - truncateSha, - toggleDiscussionHandler() { - this.toggleDiscussion({ discussionId: this.discussion.id }); - }, showReplyForm() { this.isReplying = true; }, @@ -311,43 +244,7 @@ export default { class="discussion js-discussion-container" data-qa-selector="discussion_content" > - <div v-if="shouldRenderDiffs" class="discussion-header note-wrapper"> - <div v-once class="timeline-icon align-self-start flex-shrink-0"> - <user-avatar-link - v-if="author" - :link-href="author.path" - :img-src="author.avatar_url" - :img-alt="author.name" - :img-size="40" - /> - </div> - <div class="timeline-content w-100"> - <note-header - :author="author" - :created-at="firstNote.created_at" - :note-id="firstNote.id" - :include-toggle="true" - :expanded="discussion.expanded" - @toggleHandler="toggleDiscussionHandler" - > - <span v-html="actionText"></span> - </note-header> - <note-edited-text - v-if="discussion.resolved" - :edited-at="discussion.resolved_at" - :edited-by="discussion.resolved_by" - :action-text="resolvedText" - class-name="discussion-headline-light js-discussion-headline" - /> - <note-edited-text - v-else-if="lastUpdatedAt" - :edited-at="lastUpdatedAt" - :edited-by="lastUpdatedBy" - action-text="Last updated" - class-name="discussion-headline-light js-discussion-headline" - /> - </div> - </div> + <diff-discussion-header v-if="shouldRenderDiffs" :discussion="discussion" /> <div v-if="!shouldHideDiscussionBody" class="discussion-body"> <component :is="wrapperComponent" diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index c6c97489e5e..9d1de4ef8a0 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -122,6 +122,8 @@ export default { this.toggleAward({ awardName, noteId }); }); } + + window.addEventListener('hashchange', this.handleHashChanged); }, updated() { this.$nextTick(() => { @@ -131,6 +133,7 @@ export default { }, beforeDestroy() { this.stopPolling(); + window.removeEventListener('hashchange', this.handleHashChanged); }, methods: { ...mapActions([ @@ -138,7 +141,6 @@ export default { 'fetchDiscussions', 'poll', 'toggleAward', - 'scrollToNoteIfNeeded', 'setNotesData', 'setNoteableData', 'setUserData', @@ -151,6 +153,13 @@ export default { 'convertToDiscussion', 'stopPolling', ]), + handleHashChanged() { + const noteId = this.checkLocationHash(); + + if (noteId) { + this.setTargetNoteHash(getLocationHash()); + } + }, fetchNotes() { if (this.isFetching) return null; @@ -194,6 +203,8 @@ export default { this.expandDiscussion({ discussionId: discussion.id }); } } + + return noteId; }, startReplying(discussionId) { return this.convertToDiscussion(discussionId) diff --git a/app/assets/javascripts/notes/mixins/description_version_history.js b/app/assets/javascripts/notes/mixins/description_version_history.js new file mode 100644 index 00000000000..12d80f3faa2 --- /dev/null +++ b/app/assets/javascripts/notes/mixins/description_version_history.js @@ -0,0 +1,12 @@ +// Placeholder for GitLab FOSS +// Actual implementation: ee/app/assets/javascripts/notes/mixins/description_version_history.js +export default { + computed: { + canSeeDescriptionVersion() {}, + shouldShowDescriptionVersion() {}, + descriptionVersionToggleIcon() {}, + }, + methods: { + toggleDescriptionVersion() {}, + }, +}; diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 004035ea1d4..82c291379ec 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -12,6 +12,7 @@ import service from '../services/notes_service'; import loadAwardsHandler from '../../awards_handler'; import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; +import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; import { __ } from '~/locale'; import Api from '~/api'; @@ -475,5 +476,20 @@ export const convertToDiscussion = ({ commit }, noteId) => export const removeConvertedDiscussion = ({ commit }, noteId) => commit(types.REMOVE_CONVERTED_DISCUSSION, noteId); +export const fetchDescriptionVersion = (_, { endpoint, startingVersion }) => { + let requestUrl = endpoint; + + if (startingVersion) { + requestUrl = mergeUrlParams({ start_version_id: startingVersion }, requestUrl); + } + + return axios + .get(requestUrl) + .then(res => res.data) + .catch(() => { + Flash(__('Something went wrong while fetching description changes. Please try again.')); + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/notes/stores/collapse_utils.js b/app/assets/javascripts/notes/stores/collapse_utils.js index bee6d4f0329..3cdcc7a05b8 100644 --- a/app/assets/javascripts/notes/stores/collapse_utils.js +++ b/app/assets/javascripts/notes/stores/collapse_utils.js @@ -1,34 +1,9 @@ -import { n__, s__, sprintf } from '~/locale'; import { DESCRIPTION_TYPE } from '../constants'; /** - * Changes the description from a note, returns 'changed the description n number of times' - */ -export const changeDescriptionNote = (note, descriptionChangedTimes, timeDifferenceMinutes) => { - const descriptionNote = Object.assign({}, note); - - descriptionNote.note_html = sprintf( - s__(`MergeRequest| - %{paragraphStart}changed the description %{descriptionChangedTimes} times %{timeDifferenceMinutes}%{paragraphEnd}`), - { - paragraphStart: '<p dir="auto">', - paragraphEnd: '</p>', - descriptionChangedTimes, - timeDifferenceMinutes: n__('within %d minute ', 'within %d minutes ', timeDifferenceMinutes), - }, - false, - ); - - descriptionNote.times_updated = descriptionChangedTimes; - - return descriptionNote; -}; - -/** * Checks the time difference between two notes from their 'created_at' dates * returns an integer */ - export const getTimeDifferenceMinutes = (noteBeggining, noteEnd) => { const descriptionNoteBegin = new Date(noteBeggining.created_at); const descriptionNoteEnd = new Date(noteEnd.created_at); @@ -57,7 +32,6 @@ export const isDescriptionSystemNote = note => note.system && note.note === DESC export const collapseSystemNotes = notes => { let lastDescriptionSystemNote = null; let lastDescriptionSystemNoteIndex = -1; - let descriptionChangedTimes = 1; return notes.slice(0).reduce((acc, currentNote) => { const note = currentNote.notes[0]; @@ -70,32 +44,24 @@ export const collapseSystemNotes = notes => { } else if (lastDescriptionSystemNote) { const timeDifferenceMinutes = getTimeDifferenceMinutes(lastDescriptionSystemNote, note); - // are they less than 10 minutes apart? - if (timeDifferenceMinutes > 10) { - // reset counter - descriptionChangedTimes = 1; + // are they less than 10 minutes apart from the same user? + if (timeDifferenceMinutes > 10 || note.author.id !== lastDescriptionSystemNote.author.id) { // update the previous system note lastDescriptionSystemNote = note; lastDescriptionSystemNoteIndex = acc.length; } else { - // increase counter - descriptionChangedTimes += 1; + // set the first version to fetch grouped system note versions + note.start_description_version_id = lastDescriptionSystemNote.description_version_id; // delete the previous one acc.splice(lastDescriptionSystemNoteIndex, 1); - // replace the text of the current system note with the collapsed note. - currentNote.notes.splice( - 0, - 1, - changeDescriptionNote(note, descriptionChangedTimes, timeDifferenceMinutes), - ); - // update the previous system note index lastDescriptionSystemNoteIndex = acc.length; } } } + acc.push(currentNote); return acc; }, []); diff --git a/app/assets/javascripts/pages/admin/abuse_reports/index.js b/app/assets/javascripts/pages/admin/abuse_reports/index.js index d76b1f174fc..d97e24d9e0b 100644 --- a/app/assets/javascripts/pages/admin/abuse_reports/index.js +++ b/app/assets/javascripts/pages/admin/abuse_reports/index.js @@ -1,3 +1,8 @@ +/* eslint-disable no-new */ import AbuseReports from './abuse_reports'; +import UsersSelect from '~/users_select'; -document.addEventListener('DOMContentLoaded', () => new AbuseReports()); +document.addEventListener('DOMContentLoaded', () => { + new AbuseReports(); + new UsersSelect(); +}); diff --git a/app/assets/javascripts/pages/admin/clusters/index.js b/app/assets/javascripts/pages/admin/clusters/index.js index 43992938d07..4d04c37caa7 100644 --- a/app/assets/javascripts/pages/admin/clusters/index.js +++ b/app/assets/javascripts/pages/admin/clusters/index.js @@ -1,21 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/create_cluster/gke_cluster'; - -function initGcpSignupCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); -} +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'admin:clusters:new', - 'admin:clusters:create_gcp', - 'admin:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - initGcpSignupCallout(); - initGkeDropdowns(); - } + initCreateCluster(document, gon); }); diff --git a/app/assets/javascripts/pages/groups/index.js b/app/assets/javascripts/pages/groups/index.js index a33d242908b..4d04c37caa7 100644 --- a/app/assets/javascripts/pages/groups/index.js +++ b/app/assets/javascripts/pages/groups/index.js @@ -1,21 +1,5 @@ -import PersistentUserCallout from '~/persistent_user_callout'; -import initGkeDropdowns from '~/create_cluster/gke_cluster'; - -function initGcpSignupCallout() { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); -} +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'groups:clusters:new', - 'groups:clusters:create_gcp', - 'groups:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - initGcpSignupCallout(); - initGkeDropdowns(); - } + initCreateCluster(document, gon); }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index dcdee77a8ab..090e1a2bc6d 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -1,3 +1,4 @@ +import initIssuablesList from '~/issuables_list'; import projectSelect from '~/project_select'; import initFilteredSearch from '~/pages/search/init_filtered_search'; import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar'; @@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => { IssuableFilteredSearchTokenKeys.addExtraTokensForIssues(); issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX); + initIssuablesList(); + initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, isGroupDecendent: true, diff --git a/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js new file mode 100644 index 00000000000..1d68ccd724d --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/fetch_group_path_availability.js @@ -0,0 +1,7 @@ +import axios from '~/lib/utils/axios_utils'; + +const rootUrl = gon.relative_url_root; + +export default function fetchGroupPathAvailability(groupPath) { + return axios.get(`${rootUrl}/users/${groupPath}/suggests`); +} diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js new file mode 100644 index 00000000000..2021ad117e8 --- /dev/null +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -0,0 +1,91 @@ +import InputValidator from '~/validators/input_validator'; + +import _ from 'underscore'; +import fetchGroupPathAvailability from './fetch_group_path_availability'; +import flash from '~/flash'; +import { __ } from '~/locale'; + +const debounceTimeoutDuration = 1000; +const invalidInputClass = 'gl-field-error-outline'; +const successInputClass = 'gl-field-success-outline'; +const successMessageSelector = '.validation-success'; +const pendingMessageSelector = '.validation-pending'; +const unavailableMessageSelector = '.validation-error'; +const suggestionsMessageSelector = '.gl-path-suggestions'; + +export default class GroupPathValidator extends InputValidator { + constructor(opts = {}) { + super(); + + const container = opts.container || ''; + const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`); + + this.debounceValidateInput = _.debounce(inputDomElement => { + GroupPathValidator.validateGroupPathInput(inputDomElement); + }, debounceTimeoutDuration); + + validateElements.forEach(element => + element.addEventListener('input', this.eventHandler.bind(this)), + ); + } + + eventHandler(event) { + const inputDomElement = event.target; + + GroupPathValidator.resetInputState(inputDomElement); + this.debounceValidateInput(inputDomElement); + } + + static validateGroupPathInput(inputDomElement) { + const groupPath = inputDomElement.value; + + if (inputDomElement.checkValidity() && groupPath.length > 0) { + GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector); + + fetchGroupPathAvailability(groupPath) + .then(({ data }) => data) + .then(data => { + GroupPathValidator.setInputState(inputDomElement, !data.exists); + GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector, false); + GroupPathValidator.setMessageVisibility( + inputDomElement, + data.exists ? unavailableMessageSelector : successMessageSelector, + ); + + if (data.exists) { + GroupPathValidator.showSuggestions(inputDomElement, data.suggests); + } + }) + .catch(() => flash(__('An error occurred while validating group path'))); + } + } + + static showSuggestions(inputDomElement, suggestions) { + const messageElement = inputDomElement.parentElement.parentElement.querySelector( + suggestionsMessageSelector, + ); + const textSuggestions = suggestions && suggestions.length > 0 ? suggestions.join(', ') : 'none'; + messageElement.textContent = textSuggestions; + } + + static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) { + const messageElement = inputDomElement.parentElement.parentElement.querySelector( + messageSelector, + ); + messageElement.classList.toggle('hide', !isVisible); + } + + static setInputState(inputDomElement, success = true) { + inputDomElement.classList.toggle(successInputClass, success); + inputDomElement.classList.toggle(invalidInputClass, !success); + } + + static resetInputState(inputDomElement) { + GroupPathValidator.setMessageVisibility(inputDomElement, successMessageSelector, false); + GroupPathValidator.setMessageVisibility(inputDomElement, unavailableMessageSelector, false); + + if (inputDomElement.checkValidity()) { + inputDomElement.classList.remove(successInputClass, invalidInputClass); + } + } +} diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index 57b53eb9e5d..0710fefe70c 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,8 +1,14 @@ +import $ from 'jquery'; import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; import initAvatarPicker from '~/avatar_picker'; +import GroupPathValidator from './group_path_validator'; document.addEventListener('DOMContentLoaded', () => { + const parentId = $('#group_parent_id'); + if (!parentId.val()) { + new GroupPathValidator(); // eslint-disable-line no-new + } BindInOut.initAll(); new Group(); // eslint-disable-line no-new initAvatarPicker(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 84e5bb3c46e..aee67899ca2 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -3,6 +3,7 @@ import commitPipelineStatus from '~/projects/tree/components/commit_pipeline_sta import BlobViewer from '~/blob/viewer/index'; import initBlob from '~/pages/projects/init_blob'; import GpgBadges from '~/gpg_badges'; +import '~/sourcegraph/load'; document.addEventListener('DOMContentLoaded', () => { new BlobViewer(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/clusters/new/index.js b/app/assets/javascripts/pages/projects/clusters/new/index.js deleted file mode 100644 index 14d5ab21555..00000000000 --- a/app/assets/javascripts/pages/projects/clusters/new/index.js +++ /dev/null @@ -1,13 +0,0 @@ -document.addEventListener('DOMContentLoaded', () => { - if (gon.features.createEksClusters) { - import(/* webpackChunkName: 'eks_cluster' */ '~/create_cluster/eks_cluster') - .then(({ default: initCreateEKSCluster }) => { - const el = document.querySelector('.js-create-eks-cluster-form-container'); - - if (el) { - initCreateEKSCluster(el); - } - }) - .catch(() => {}); - } -}); diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index f091c01fc98..397f9faf6fe 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,5 +1,5 @@ import ClustersBundle from '~/clusters/clusters_bundle'; -import initGkeNamespace from '~/projects/gke_cluster_namespace'; +import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 5aa4734244e..0eb6f231839 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -9,6 +9,7 @@ import initNotes from '~/init_notes'; import initChangesDropdown from '~/init_changes_dropdown'; import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import { fetchCommitMergeRequests } from '~/commit_merge_requests'; +import '~/sourcegraph/load'; document.addEventListener('DOMContentLoaded', () => { const hasPerfBar = document.querySelector('.with-performance-bar'); diff --git a/app/assets/javascripts/pages/projects/error_tracking/details/index.js b/app/assets/javascripts/pages/projects/error_tracking/details/index.js new file mode 100644 index 00000000000..25d1c744e1b --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/details/index.js @@ -0,0 +1,5 @@ +import ErrorTrackingDetails from '~/error_tracking/details'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTrackingDetails(); +}); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index.js b/app/assets/javascripts/pages/projects/error_tracking/index.js deleted file mode 100644 index 5a8fe137e9a..00000000000 --- a/app/assets/javascripts/pages/projects/error_tracking/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import ErrorTracking from '~/error_tracking'; - -document.addEventListener('DOMContentLoaded', () => { - ErrorTracking(); -}); diff --git a/app/assets/javascripts/pages/projects/error_tracking/index/index.js b/app/assets/javascripts/pages/projects/error_tracking/index/index.js new file mode 100644 index 00000000000..ead81cd5d2d --- /dev/null +++ b/app/assets/javascripts/pages/projects/error_tracking/index/index.js @@ -0,0 +1,5 @@ +import ErrorTrackingList from '~/error_tracking/list'; + +document.addEventListener('DOMContentLoaded', () => { + ErrorTrackingList(); +}); diff --git a/app/assets/javascripts/pages/projects/graphs/show/index.js b/app/assets/javascripts/pages/projects/graphs/show/index.js index f79c386b59e..09d9c78c446 100644 --- a/app/assets/javascripts/pages/projects/graphs/show/index.js +++ b/app/assets/javascripts/pages/projects/graphs/show/index.js @@ -1,25 +1,3 @@ -import $ from 'jquery'; -import flash from '~/flash'; -import { __ } from '~/locale'; -import axios from '~/lib/utils/axios_utils'; -import ContributorsStatGraph from './stat_graph_contributors'; +import initContributorsGraphs from '~/contributors'; -document.addEventListener('DOMContentLoaded', () => { - const url = document.querySelector('.js-graphs-show').dataset.projectGraphPath; - - axios - .get(url) - .then(({ data }) => { - const graph = new ContributorsStatGraph(); - graph.init(data); - - $('#brush_change').change(() => { - graph.change_date_header(); - graph.redraw_authors(); - }); - - $('.stat-graph').fadeIn(); - $('.loading-graph').hide(); - }) - .catch(() => flash(__('Error fetching contributors data.'))); -}); +document.addEventListener('DOMContentLoaded', initContributorsGraphs); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js deleted file mode 100644 index 5b873e6b909..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors.js +++ /dev/null @@ -1,140 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { n__, s__, createDateTimeFormat, sprintf } from '~/locale'; -import { - ContributorsGraph, - ContributorsAuthorGraph, - ContributorsMasterGraph, -} from './stat_graph_contributors_graph'; -import ContributorsStatGraphUtil from './stat_graph_contributors_util'; - -export default (function() { - function ContributorsStatGraph() { - this.dateFormat = createDateTimeFormat({ year: 'numeric', month: 'long', day: 'numeric' }); - } - - ContributorsStatGraph.prototype.init = function(log) { - var author_commits, total_commits; - this.parsed_log = ContributorsStatGraphUtil.parse_log(log); - this.set_current_field('commits'); - total_commits = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - author_commits = ContributorsStatGraphUtil.get_author_data(this.parsed_log, this.field); - this.add_master_graph(total_commits); - this.add_authors_graph(author_commits); - return this.change_date_header(); - }; - - ContributorsStatGraph.prototype.add_master_graph = function(total_data) { - this.master_graph = new ContributorsMasterGraph(total_data); - return this.master_graph.draw(); - }; - - ContributorsStatGraph.prototype.add_authors_graph = function(author_data) { - var limited_author_data; - this.authors = []; - limited_author_data = author_data.slice(0, 100); - return _.each( - limited_author_data, - (function(_this) { - return function(d) { - var author_graph, author_header; - author_header = _this.create_author_header(d); - $('.contributors-list').append(author_header); - - author_graph = new ContributorsAuthorGraph(d.dates); - _this.authors[d.author_name] = author_graph; - return author_graph.draw(); - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.format_author_commit_info = function(author) { - var commits; - commits = $('<span/>', { - class: 'graph-author-commits-count', - }); - commits.text(n__('%d commit', '%d commits', author.commits)); - return $('<span/>').append(commits); - }; - - ContributorsStatGraph.prototype.create_author_header = function(author) { - var author_commit_info, author_commit_info_span, author_email, author_name, list_item; - list_item = $('<li/>', { - class: 'person', - style: 'display: block;', - }); - author_name = $(`<h4>${author.author_name}</h4>`); - author_email = $(`<p class="graph-author-email">${author.author_email}</p>`); - author_commit_info_span = $('<span/>', { - class: 'commits', - }); - author_commit_info = this.format_author_commit_info(author); - author_commit_info_span.html(author_commit_info); - list_item.append(author_name); - list_item.append(author_email); - list_item.append(author_commit_info_span); - return list_item; - }; - - ContributorsStatGraph.prototype.redraw_master = function() { - var total_data; - total_data = ContributorsStatGraphUtil.get_total_data(this.parsed_log, this.field); - this.master_graph.set_data(total_data); - return this.master_graph.redraw(); - }; - - ContributorsStatGraph.prototype.redraw_authors = function() { - $('ol').html(''); - - const { x_domain } = ContributorsGraph.prototype; - const author_commits = ContributorsStatGraphUtil.get_author_data( - this.parsed_log, - this.field, - x_domain, - ); - - return _.each( - author_commits, - (function(_this) { - return function(d) { - _this.redraw_author_commit_info(d); - if (_this.authors[d.author_name] != null) { - $(_this.authors[d.author_name].list_item).appendTo('ol'); - _this.authors[d.author_name].set_data(d.dates); - return _this.authors[d.author_name].redraw(); - } - return ''; - }; - })(this), - ); - }; - - ContributorsStatGraph.prototype.set_current_field = function(field) { - return (this.field = field); - }; - - ContributorsStatGraph.prototype.change_date_header = function() { - const { x_domain } = ContributorsGraph.prototype; - const formattedDateRange = sprintf(s__('ContributorsPage|%{startDate} – %{endDate}'), { - startDate: this.dateFormat.format(new Date(x_domain[0])), - endDate: this.dateFormat.format(new Date(x_domain[1])), - }); - return $('#date_header').text(formattedDateRange); - }; - - ContributorsStatGraph.prototype.redraw_author_commit_info = function(author) { - var author_commit_info, author_list_item, $author; - $author = this.authors[author.author_name]; - if ($author != null) { - author_list_item = $(this.authors[author.author_name].list_item); - author_commit_info = this.format_author_commit_info(author); - return author_list_item.find('span').html(author_commit_info); - } - return ''; - }; - - return ContributorsStatGraph; -})(); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js deleted file mode 100644 index 86794800f87..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_graph.js +++ /dev/null @@ -1,379 +0,0 @@ -/* eslint-disable func-names, no-restricted-syntax, no-use-before-define, no-param-reassign, new-cap, no-underscore-dangle, no-return-assign, no-else-return, no-shadow */ - -import $ from 'jquery'; -import _ from 'underscore'; -import { extent, max } from 'd3-array'; -import { select, event as d3Event } from 'd3-selection'; -import { scaleTime, scaleLinear } from 'd3-scale'; -import { axisLeft, axisBottom } from 'd3-axis'; -import { area } from 'd3-shape'; -import { brushX } from 'd3-brush'; -import { timeParse } from 'd3-time-format'; -import { dateTickFormat } from '~/lib/utils/tick_formats'; - -const d3 = { - extent, - max, - select, - scaleTime, - scaleLinear, - axisLeft, - axisBottom, - area, - brushX, - timeParse, -}; - -const hasProp = {}.hasOwnProperty; -const extend = function(child, parent) { - for (const key in parent) { - if (hasProp.call(parent, key)) child[key] = parent[key]; - } - function ctor() { - this.constructor = child; - } - ctor.prototype = parent.prototype; - child.prototype = new ctor(); - child.__super__ = parent.prototype; - return child; -}; - -export const ContributorsGraph = (function() { - function ContributorsGraph() {} - - ContributorsGraph.prototype.MARGIN = { - top: 20, - right: 10, - bottom: 30, - left: 40, - }; - - ContributorsGraph.prototype.x_domain = null; - - ContributorsGraph.prototype.y_domain = null; - - ContributorsGraph.prototype.dates = []; - - ContributorsGraph.prototype.determine_width = function(baseWidth, $parentElement) { - const parentPaddingWidth = - parseFloat($parentElement.css('padding-left')) + - parseFloat($parentElement.css('padding-right')); - const marginWidth = this.MARGIN.left + this.MARGIN.right; - return baseWidth - parentPaddingWidth - marginWidth; - }; - - ContributorsGraph.set_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = data); - }; - - ContributorsGraph.set_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_x_domain = function(data) { - return (ContributorsGraph.prototype.x_domain = d3.extent(data, d => d.date)); - }; - - ContributorsGraph.init_y_domain = function(data) { - return (ContributorsGraph.prototype.y_domain = [ - 0, - d3.max(data, d => (d.commits = d.commits || d.additions || d.deletions)), - ]); - }; - - ContributorsGraph.init_domain = function(data) { - ContributorsGraph.init_x_domain(data); - return ContributorsGraph.init_y_domain(data); - }; - - ContributorsGraph.set_dates = function(data) { - return (ContributorsGraph.prototype.dates = data); - }; - - ContributorsGraph.prototype.set_x_domain = function() { - return this.x.domain(this.x_domain); - }; - - ContributorsGraph.prototype.set_y_domain = function() { - return this.y.domain(this.y_domain); - }; - - ContributorsGraph.prototype.set_domain = function() { - this.set_x_domain(); - return this.set_y_domain(); - }; - - ContributorsGraph.prototype.create_scale = function(width, height) { - this.x = d3 - .scaleTime() - .range([0, width]) - .clamp(true); - return (this.y = d3 - .scaleLinear() - .range([height, 0]) - .nice()); - }; - - ContributorsGraph.prototype.draw_x_axis = function() { - return this.svg - .append('g') - .attr('class', 'x axis') - .attr('transform', `translate(0, ${this.height})`) - .call(this.x_axis); - }; - - ContributorsGraph.prototype.draw_y_axis = function() { - return this.svg - .append('g') - .attr('class', 'y axis') - .call(this.y_axis); - }; - - ContributorsGraph.prototype.set_data = function(data) { - return (this.data = data); - }; - - return ContributorsGraph; -})(); - -export const ContributorsMasterGraph = (function(superClass) { - extend(ContributorsMasterGraph, superClass); - - function ContributorsMasterGraph(data1) { - const $parentElement = $('#contributors-master'); - - this.data = data1; - this.update_content = this.update_content.bind(this); - this.width = this.determine_width($('.js-graphs-show').width(), $parentElement); - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.brush = null; - this.x_max_domain = null; - } - - ContributorsMasterGraph.prototype.process_dates = function(data) { - const dates = this.get_dates(data); - this.parse_dates(data); - return ContributorsGraph.set_dates(dates); - }; - - ContributorsMasterGraph.prototype.get_dates = function(data) { - return _.pluck(data, 'date'); - }; - - ContributorsMasterGraph.prototype.parse_dates = function(data) { - const parseDate = d3.timeParse('%Y-%m-%d'); - return data.forEach(d => (d.date = parseDate(d.date))); - }; - - ContributorsMasterGraph.prototype.create_scale = function() { - return ContributorsMasterGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsMasterGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsMasterGraph.prototype.create_svg = function() { - this.svg = d3 - .select('#contributors-master') - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'tint-box') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsMasterGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => x(d.date)) - .y0(this.height) - .y1(d => { - d.commits = d.commits || d.additions || d.deletions; - return y(d.commits); - })); - }; - - ContributorsMasterGraph.prototype.create_brush = function() { - return (this.brush = d3 - .brushX(this.x) - .extent([[this.x.range()[0], 0], [this.x.range()[1], this.height]]) - .on('end', this.update_content)); - }; - - ContributorsMasterGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area') - .attr('d', this.area); - }; - - ContributorsMasterGraph.prototype.add_brush = function() { - return this.svg - .append('g') - .attr('class', 'selection') - .call(this.brush) - .selectAll('rect') - .attr('height', this.height); - }; - - ContributorsMasterGraph.prototype.update_content = function() { - // d3Event.selection replaces the function brush.empty() calls - if (d3Event.selection != null) { - ContributorsGraph.set_x_domain(d3Event.selection.map(this.x.invert)); - } else { - ContributorsGraph.set_x_domain(this.x_max_domain); - } - return $('#brush_change').trigger('change'); - }; - - ContributorsMasterGraph.prototype.draw = function() { - this.process_dates(this.data); - this.create_scale(); - this.create_axes(); - ContributorsGraph.init_domain(this.data); - this.x_max_domain = this.x_domain; - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.create_brush(); - this.draw_path(this.data); - this.draw_x_axis(); - this.draw_y_axis(); - return this.add_brush(); - }; - - ContributorsMasterGraph.prototype.redraw = function() { - this.process_dates(this.data); - ContributorsGraph.set_y_domain(this.data); - this.set_y_domain(); - this.svg.select('path').datum(this.data); - this.svg.select('path').attr('d', this.area); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsMasterGraph; -})(ContributorsGraph); - -export const ContributorsAuthorGraph = (function(superClass) { - extend(ContributorsAuthorGraph, superClass); - - function ContributorsAuthorGraph(data1) { - const $parentElements = $('.person'); - - this.data = data1; - // Don't split graph size in half for mobile devices. - if ($(window).width() < 790) { - this.width = this.determine_width($('.js-graphs-show').width(), $parentElements); - } else { - this.width = this.determine_width($('.js-graphs-show').width() / 2, $parentElements); - } - this.height = 200; - this.x = null; - this.y = null; - this.x_axis = null; - this.y_axis = null; - this.area = null; - this.svg = null; - this.list_item = null; - } - - ContributorsAuthorGraph.prototype.create_scale = function() { - return ContributorsAuthorGraph.__super__.create_scale.call(this, this.width, this.height); - }; - - ContributorsAuthorGraph.prototype.create_axes = function() { - this.x_axis = d3 - .axisBottom() - .scale(this.x) - .ticks(8) - .tickFormat(dateTickFormat); - return (this.y_axis = d3 - .axisLeft() - .scale(this.y) - .ticks(5)); - }; - - ContributorsAuthorGraph.prototype.create_area = function(x, y) { - return (this.area = d3 - .area() - .x(d => { - const parseDate = d3.timeParse('%Y-%m-%d'); - return x(parseDate(d)); - }) - .y0(this.height) - .y1( - (function(_this) { - return function(d) { - if (_this.data[d] != null) { - return y(_this.data[d]); - } else { - return y(0); - } - }; - })(this), - )); - }; - - ContributorsAuthorGraph.prototype.create_svg = function() { - const persons = document.querySelectorAll('.person'); - this.list_item = persons[persons.length - 1]; - this.svg = d3 - .select(this.list_item) - .append('svg') - .attr('width', this.width + this.MARGIN.left + this.MARGIN.right) - .attr('height', this.height + this.MARGIN.top + this.MARGIN.bottom) - .attr('class', 'spark') - .append('g') - .attr('transform', `translate(${this.MARGIN.left},${this.MARGIN.top})`); - return this.svg; - }; - - ContributorsAuthorGraph.prototype.draw_path = function(data) { - return this.svg - .append('path') - .datum(data) - .attr('class', 'area-contributor') - .attr('d', this.area); - }; - - ContributorsAuthorGraph.prototype.draw = function() { - this.create_scale(); - this.create_axes(); - this.set_domain(); - this.create_area(this.x, this.y); - this.create_svg(); - this.draw_path(this.dates); - this.draw_x_axis(); - return this.draw_y_axis(); - }; - - ContributorsAuthorGraph.prototype.redraw = function() { - this.set_domain(); - this.svg.select('path').datum(this.dates); - this.svg.select('path').attr('d', this.area); - this.svg.select('.x.axis').call(this.x_axis); - return this.svg.select('.y.axis').call(this.y_axis); - }; - - return ContributorsAuthorGraph; -})(ContributorsGraph); diff --git a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js b/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js deleted file mode 100644 index a89a13fe37a..00000000000 --- a/app/assets/javascripts/pages/projects/graphs/show/stat_graph_contributors_util.js +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable func-names, no-var, one-var, camelcase, no-param-reassign, no-return-assign, consistent-return, no-cond-assign, no-else-return */ -import _ from 'underscore'; - -export default { - parse_log(log) { - var by_author, by_email, data, entry, i, len, total, normalized_email; - total = {}; - by_author = {}; - by_email = {}; - for (i = 0, len = log.length; i < len; i += 1) { - entry = log[i]; - if (total[entry.date] == null) { - this.add_date(entry.date, total); - } - normalized_email = entry.author_email.toLowerCase(); - data = by_author[entry.author_name] || by_email[normalized_email]; - if (data == null) { - data = this.add_author(entry, by_author, by_email); - } - if (!data[entry.date]) { - this.add_date(entry.date, data); - } - this.store_data(entry, total[entry.date], data[entry.date]); - } - total = _.toArray(total); - by_author = _.toArray(by_author); - return { - total, - by_author, - }; - }, - add_date(date, collection) { - collection[date] = {}; - return (collection[date].date = date); - }, - add_author(author, by_author, by_email) { - var data, normalized_email; - data = {}; - data.author_name = author.author_name; - data.author_email = author.author_email; - normalized_email = author.author_email.toLowerCase(); - by_author[author.author_name] = data; - by_email[normalized_email] = data; - return data; - }, - store_data(entry, total, by_author) { - this.store_commits(total, by_author); - this.store_additions(entry, total, by_author); - return this.store_deletions(entry, total, by_author); - }, - store_commits(total, by_author) { - this.add(total, 'commits', 1); - return this.add(by_author, 'commits', 1); - }, - add(collection, field, value) { - if (collection[field] == null) { - collection[field] = 0; - } - return (collection[field] += value); - }, - store_additions(entry, total, by_author) { - if (entry.additions == null) { - entry.additions = 0; - } - this.add(total, 'additions', entry.additions); - return this.add(by_author, 'additions', entry.additions); - }, - store_deletions(entry, total, by_author) { - if (entry.deletions == null) { - entry.deletions = 0; - } - this.add(total, 'deletions', entry.deletions); - return this.add(by_author, 'deletions', entry.deletions); - }, - get_total_data(parsed_log, field) { - var log, total_data; - log = parsed_log.total; - total_data = this.pick_field(log, field); - return _.sortBy(total_data, d => d.date); - }, - pick_field(log, field) { - var total_data; - total_data = []; - _.each(log, d => total_data.push(_.pick(d, [field, 'date']))); - return total_data; - }, - get_author_data(parsed_log, field, date_range) { - var author_data, log; - if (date_range == null) { - date_range = null; - } - log = parsed_log.by_author; - author_data = []; - _.each( - log, - (function(_this) { - return function(log_entry) { - var parsed_log_entry; - parsed_log_entry = _this.parse_log_entry(log_entry, field, date_range); - if (!_.isEmpty(parsed_log_entry.dates)) { - return author_data.push(parsed_log_entry); - } - }; - })(this), - ); - return _.sortBy(author_data, d => d[field]).reverse(); - }, - parse_log_entry(log_entry, field, date_range) { - var parsed_entry; - parsed_entry = {}; - - parsed_entry.author_name = log_entry.author_name; - parsed_entry.author_email = log_entry.author_email; - parsed_entry.dates = {}; - - parsed_entry.commits = 0; - parsed_entry.additions = 0; - parsed_entry.deletions = 0; - - _.each( - _.omit(log_entry, 'author_name', 'author_email'), - (function(_this) { - return function(value) { - if (_this.in_range(value.date, date_range)) { - parsed_entry.dates[value.date] = value[field]; - parsed_entry.commits += value.commits; - parsed_entry.additions += value.additions; - return (parsed_entry.deletions += value.deletions); - } - }; - })(this), - ); - return parsed_entry; - }, - in_range(date, date_range) { - var ref; - if (date_range === null || (date_range[0] <= (ref = new Date(date)) && ref <= date_range[1])) { - return true; - } else { - return false; - } - }, -}; diff --git a/app/assets/javascripts/pages/projects/index.js b/app/assets/javascripts/pages/projects/index.js index 196798a9076..190d0806c28 100644 --- a/app/assets/javascripts/pages/projects/index.js +++ b/app/assets/javascripts/pages/projects/index.js @@ -1,24 +1,9 @@ -import initGkeDropdowns from '~/create_cluster/gke_cluster'; -import initGkeNamespace from '~/projects/gke_cluster_namespace'; -import PersistentUserCallout from '../../persistent_user_callout'; import Project from './project'; import ShortcutsNavigation from '../../behaviors/shortcuts/shortcuts_navigation'; +import initCreateCluster from '~/create_cluster/init_create_cluster'; document.addEventListener('DOMContentLoaded', () => { - const { page } = document.body.dataset; - const newClusterViews = [ - 'projects:clusters:new', - 'projects:clusters:create_gcp', - 'projects:clusters:create_user', - ]; - - if (newClusterViews.indexOf(page) > -1) { - const callout = document.querySelector('.gcp-signup-offer'); - PersistentUserCallout.factory(callout); - - initGkeDropdowns(); - initGkeNamespace(); - } + initCreateCluster(document, gon); new Project(); // eslint-disable-line no-new new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index fa1de1f13cb..16034313af2 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -5,6 +5,7 @@ import { handleLocationHash } from '~/lib/utils/common_utils'; import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; +import initSourcegraph from '~/sourcegraph'; import initWidget from '../../../vue_merge_request_widget'; export default function() { @@ -19,4 +20,5 @@ export default function() { handleLocationHash(); howToMerge(); initWidget(); + initSourcegraph(); } diff --git a/app/assets/javascripts/pages/projects/network/network.js b/app/assets/javascripts/pages/projects/network/network.js index 43417fa9702..5f2014f1631 100644 --- a/app/assets/javascripts/pages/projects/network/network.js +++ b/app/assets/javascripts/pages/projects/network/network.js @@ -1,22 +1,19 @@ -/* eslint-disable func-names, no-var */ - import $ from 'jquery'; import BranchGraph from '../../../network/branch_graph'; -export default (function() { - function Network(opts) { - var vph; - $('#filter_ref').click(function() { - return $(this) - .closest('form') - .submit(); - }); - this.branch_graph = new BranchGraph($('.network-graph'), opts); - vph = $(window).height() - 250; - $('.network-graph').css({ - height: `${vph}px`, - }); +const vph = $(window).height() - 250; + +export default class Network { + constructor(opts) { + this.opts = opts; + this.filter_ref = $('#filter_ref'); + this.network_graph = $('.network-graph'); + this.filter_ref.click(() => this.submit()); + this.branch_graph = new BranchGraph(this.network_graph, this.opts); + this.network_graph.css({ height: `${vph}px` }); } - return Network; -})(); + submit() { + return this.filter_ref.closest('form').submit(); + } +} diff --git a/app/assets/javascripts/pages/projects/pages_domains/form.js b/app/assets/javascripts/pages/projects/pages_domains/form.js index cef8e92610c..ae5368179b1 100644 --- a/app/assets/javascripts/pages/projects/pages_domains/form.js +++ b/app/assets/javascripts/pages/projects/pages_domains/form.js @@ -1,17 +1,23 @@ import setupToggleButtons from '~/toggle_buttons'; +function updateVisibility(selector, isVisible) { + Array.from(document.querySelectorAll(selector)).forEach(el => { + if (isVisible) { + el.classList.remove('d-none'); + } else { + el.classList.add('d-none'); + } + }); +} + export default () => { const toggleContainer = document.querySelector('.js-auto-ssl-toggle-container'); if (toggleContainer) { const onToggleButtonClicked = isAutoSslEnabled => { - Array.from(document.querySelectorAll('.js-shown-unless-auto-ssl')).forEach(el => { - if (isAutoSslEnabled) { - el.classList.add('d-none'); - } else { - el.classList.remove('d-none'); - } - }); + updateVisibility('.js-shown-unless-auto-ssl', !isAutoSslEnabled); + + updateVisibility('.js-shown-if-auto-ssl', isAutoSslEnabled); Array.from(document.querySelectorAll('.js-enabled-unless-auto-ssl')).forEach(el => { if (isAutoSslEnabled) { diff --git a/app/assets/javascripts/pages/projects/pipelines/test_report/index.js b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js new file mode 100644 index 00000000000..7e69983c2ed --- /dev/null +++ b/app/assets/javascripts/pages/projects/pipelines/test_report/index.js @@ -0,0 +1,2 @@ +// /test_report is an alias for show +import '../show/index'; diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 435e8705803..01acfca158f 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -40,11 +40,6 @@ export default class Project { $label.text(activeText); }); - $('#modal-geo-info').data({ - cloneUrlSecondary: $this.attr('href'), - cloneUrlPrimary: $this.data('primaryUrl') || '', - }); - if (mobileCloneField) { mobileCloneField.dataset.clipboardText = url; } else { diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js index 98e19705976..a32c188909c 100644 --- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js @@ -1,9 +1,11 @@ import mountErrorTrackingForm from '~/error_tracking_settings'; import mountOperationSettings from '~/operation_settings'; +import mountGrafanaIntegration from '~/grafana_integration'; import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { mountErrorTrackingForm(); mountOperationSettings(); + mountGrafanaIntegration(); initSettingsPanels(); }); 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 89cac42abae..4802cc2ad25 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue +++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue @@ -1,7 +1,6 @@ <script> -/* eslint-disable @gitlab/vue-i18n/no-bare-strings */ import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin'; -import { __ } from '~/locale'; +import { s__ } from '~/locale'; import projectFeatureSetting from './project_feature_setting.vue'; import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; import projectSettingRow from './project_setting_row.vue'; @@ -13,7 +12,7 @@ import { } from '../constants'; import { toggleHiddenClassBySelector } from '../external'; -const PAGE_FEATURE_ACCESS_LEVEL = __('Everyone'); +const PAGE_FEATURE_ACCESS_LEVEL = s__('ProjectSettings|Everyone'); export default { components: { @@ -207,7 +206,10 @@ export default { <template> <div> <div class="project-visibility-setting"> - <project-setting-row :help-path="visibilityHelpPath" label="Project visibility"> + <project-setting-row + :help-path="visibilityHelpPath" + :label="s__('ProjectSettings|Project visibility')" + > <div class="project-feature-controls"> <div class="select-wrapper"> <select @@ -220,17 +222,17 @@ export default { <option :value="visibilityOptions.PRIVATE" :disabled="!visibilityAllowed(visibilityOptions.PRIVATE)" - >{{ __('Private') }}</option + >{{ s__('ProjectSettings|Private') }}</option > <option :value="visibilityOptions.INTERNAL" :disabled="!visibilityAllowed(visibilityOptions.INTERNAL)" - >{{ __('Internal') }}</option + >{{ s__('ProjectSettings|Internal') }}</option > <option :value="visibilityOptions.PUBLIC" :disabled="!visibilityAllowed(visibilityOptions.PUBLIC)" - >{{ __('Public') }}</option + >{{ s__('ProjectSettings|Public') }}</option > </select> <i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i> @@ -243,14 +245,15 @@ export default { type="hidden" name="project[request_access_enabled]" /> - <input v-model="requestAccessEnabled" type="checkbox" /> Allow users to request access + <input v-model="requestAccessEnabled" type="checkbox" /> + {{ s__('ProjectSettings|Allow users to request access') }} </label> </project-setting-row> </div> <div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings"> <project-setting-row - label="Issues" - help-text="Lightweight issue tracking system for this project" + :label="s__('ProjectSettings|Issues')" + :help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')" > <project-feature-setting v-model="issuesAccessLevel" @@ -258,7 +261,10 @@ export default { name="project[project_feature_attributes][issues_access_level]" /> </project-setting-row> - <project-setting-row label="Repository" help-text="View and edit files in this project"> + <project-setting-row + :label="s__('ProjectSettings|Repository')" + :help-text="s__('ProjectSettings|View and edit files in this project')" + > <project-feature-setting v-model="repositoryAccessLevel" :options="featureAccessLevelOptions" @@ -267,8 +273,8 @@ export default { </project-setting-row> <div class="project-feature-setting-group"> <project-setting-row - label="Merge requests" - help-text="Submit changes to be merged upstream" + :label="s__('ProjectSettings|Merge requests')" + :help-text="s__('ProjectSettings|Submit changes to be merged upstream')" > <project-feature-setting v-model="mergeRequestsAccessLevel" @@ -277,7 +283,10 @@ export default { name="project[project_feature_attributes][merge_requests_access_level]" /> </project-setting-row> - <project-setting-row label="Pipelines" help-text="Build, test, and deploy your changes"> + <project-setting-row + :label="s__('ProjectSettings|Pipelines')" + :help-text="s__('ProjectSettings|Build, test, and deploy your changes')" + > <project-feature-setting v-model="buildsAccessLevel" :options="repoFeatureAccessLevelOptions" @@ -288,11 +297,17 @@ export default { <project-setting-row v-if="registryAvailable" :help-path="registryHelpPath" - label="Container registry" - help-text="Every project can have its own space to store its Docker images" + :label="s__('ProjectSettings|Container registry')" + :help-text=" + s__('ProjectSettings|Every project can have its own space to store its Docker images') + " > <div v-if="showContainerRegistryPublicNote" class="text-muted"> - {{ __('Note: the container registry is always visible when a project is public') }} + {{ + s__( + 'ProjectSettings|Note: the container registry is always visible when a project is public', + ) + }} </div> <project-feature-toggle v-model="containerRegistryEnabled" @@ -303,8 +318,10 @@ export default { <project-setting-row v-if="lfsAvailable" :help-path="lfsHelpPath" - label="Git Large File Storage" - help-text="Manages large files such as audio, video, and graphics files" + :label="s__('ProjectSettings|Git Large File Storage')" + :help-text=" + s__('ProjectSettings|Manages large files such as audio, video, and graphics files') + " > <project-feature-toggle v-model="lfsEnabled" @@ -315,8 +332,10 @@ export default { <project-setting-row v-if="packagesAvailable" :help-path="packagesHelpPath" - label="Packages" - help-text="Every project can have its own space to store its packages" + :label="s__('ProjectSettings|Packages')" + :help-text=" + s__('ProjectSettings|Every project can have its own space to store its packages') + " > <project-feature-toggle v-model="packagesEnabled" @@ -325,7 +344,10 @@ export default { /> </project-setting-row> </div> - <project-setting-row label="Wiki" help-text="Pages for project documentation"> + <project-setting-row + :label="s__('ProjectSettings|Wiki')" + :help-text="s__('ProjectSettings|Pages for project documentation')" + > <project-feature-setting v-model="wikiAccessLevel" :options="featureAccessLevelOptions" @@ -333,8 +355,8 @@ export default { /> </project-setting-row> <project-setting-row - label="Snippets" - help-text="Share code pastes with others out of Git repository" + :label="s__('ProjectSettings|Snippets')" + :help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')" > <project-feature-setting v-model="snippetsAccessLevel" @@ -345,8 +367,10 @@ export default { <project-setting-row v-if="pagesAvailable && pagesAccessControlEnabled" :help-path="pagesHelpPath" - label="Pages access control" - help-text="Access control for the project's static website" + :label="s__('ProjectSettings|Pages')" + :help-text=" + s__('ProjectSettings|With GitLab Pages you can host your static websites on GitLab') + " > <project-feature-setting v-model="pagesAccessLevel" @@ -358,10 +382,13 @@ export default { <project-setting-row v-if="canDisableEmails" class="mb-3"> <label class="js-emails-disabled"> <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" /> - <input v-model="emailsDisabled" type="checkbox" /> {{ __('Disable email notifications') }} + <input v-model="emailsDisabled" type="checkbox" /> + {{ s__('ProjectSettings|Disable email notifications') }} </label> <span class="form-text text-muted">{{ - __('This setting will override user notification preferences for all project members.') + s__( + 'ProjectSettings|This setting will override user notification preferences for all project members.', + ) }}</span> </project-setting-row> </div> diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index 6aa41d0825b..370f3c6e7a2 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -48,7 +48,7 @@ document.addEventListener('DOMContentLoaded', () => { leaveByUrl('project'); if (document.getElementById('js-tree-list')) { - import('~/repository') + import('ee_else_ce/repository') .then(m => m.default()) .catch(e => { throw e; diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 7b90a3a4f6e..16d71379e31 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -42,7 +42,7 @@ document.addEventListener('DOMContentLoaded', () => { GpgBadges.fetch(); if (document.getElementById('js-tree-list')) { - import('~/repository') + import('ee_else_ce/repository') .then(m => m.default()) .catch(e => { throw e; diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 55bc93a2b13..66ee2d9303f 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -5,6 +5,7 @@ import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; +import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new @@ -20,3 +21,16 @@ document.addEventListener('DOMContentLoaded', () => { // redirected to sign-in after attempting to access a protected URL that included a fragment. preserveUrlFragment(window.location.hash); }); + +export default function trackData() { + if (gon.tracking_data) { + const tab = document.querySelector(".new-session-tabs a[href='#register-pane']"); + const { category, action, ...data } = gon.tracking_data; + + tab.addEventListener('click', () => { + Tracking.event(category, action, data); + }); + } +} + +trackData(); diff --git a/app/assets/javascripts/performance_bar/components/add_request.vue b/app/assets/javascripts/performance_bar/components/add_request.vue new file mode 100644 index 00000000000..54bca8a1b67 --- /dev/null +++ b/app/assets/javascripts/performance_bar/components/add_request.vue @@ -0,0 +1,48 @@ +import { __ } from '~/locale'; + +<script> +export default { + data() { + return { + inputEnabled: false, + urlOrRequestId: '', + }; + }, + methods: { + toggleInput() { + this.inputEnabled = !this.inputEnabled; + }, + addRequest() { + this.$emit('add-request', this.urlOrRequestId); + this.clearForm(); + }, + clearForm() { + this.urlOrRequestId = ''; + this.toggleInput(); + }, + }, +}; +</script> +<template> + <div id="peek-view-add-request" class="view"> + <form class="form-inline" @submit.prevent> + <button + class="btn-blank btn-link bold" + type="button" + :title="__(`Add request manually`)" + @click="toggleInput" + > + + + </button> + <input + v-if="inputEnabled" + v-model="urlOrRequestId" + type="text" + :placeholder="__(`URL or request ID`)" + class="form-control form-control-sm d-inline-block ml-1" + @keyup.enter="addRequest" + @keyup.esc="clearForm" + /> + </form> + </div> +</template> diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 3b07eba02b7..8ce653bf1fb 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -1,12 +1,14 @@ <script> import { glEmojiTag } from '~/emoji'; +import AddRequest from './add_request.vue'; import DetailedMetric from './detailed_metric.vue'; import RequestSelector from './request_selector.vue'; import { s__ } from '~/locale'; export default { components: { + AddRequest, DetailedMetric, RequestSelector, }, @@ -118,6 +120,7 @@ export default { > <a :href="currentRequest.details.tracing.tracing_url">{{ s__('PerformanceBar|trace') }}</a> </div> + <add-request v-on="$listeners" /> <request-selector v-if="currentRequest" :current-request="currentRequest" diff --git a/app/assets/javascripts/performance_bar/index.js b/app/assets/javascripts/performance_bar/index.js index 1ae9487f391..735c9d804ee 100644 --- a/app/assets/javascripts/performance_bar/index.js +++ b/app/assets/javascripts/performance_bar/index.js @@ -1,4 +1,6 @@ import Vue from 'vue'; +import axios from '~/lib/utils/axios_utils'; + import PerformanceBarService from './services/performance_bar_service'; import PerformanceBarStore from './stores/performance_bar_store'; @@ -32,6 +34,15 @@ export default ({ container }) => PerformanceBarService.removeInterceptor(this.interceptor); }, methods: { + addRequestManually(urlOrRequestId) { + if (urlOrRequestId.startsWith('https://') || urlOrRequestId.startsWith('http://')) { + // We don't need to do anything with the response, we just + // want to trace the request. + axios.get(urlOrRequestId); + } else { + this.loadRequestDetails(urlOrRequestId, urlOrRequestId); + } + }, loadRequestDetails(requestId, requestUrl) { if (!this.store.canTrackRequest(requestUrl)) { return; @@ -58,6 +69,9 @@ export default ({ container }) => peekUrl: this.peekUrl, profileUrl: this.profileUrl, }, + on: { + 'add-request': this.addRequestManually, + }, }); }, }); diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 3c85bb61ce8..fd59a580961 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -88,7 +88,7 @@ export default { :title="tooltipText" :class="cssClass" :disabled="isDisabled" - class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper" + class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper d-flex align-items-center justify-content-center" @click="onClickAction" > <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue index 5275de3bc8b..afb8439511f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_table_row.vue @@ -265,7 +265,11 @@ export default { <div class="table-section section-10 commit-link"> <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div> <div class="table-mobile-content"> - <ci-badge :status="pipelineStatus" :show-text="!isChildView" /> + <ci-badge + :status="pipelineStatus" + :show-text="!isChildView" + data-qa-selector="pipeline_commit_status" + /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue new file mode 100644 index 00000000000..388b300b39d --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -0,0 +1,81 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; +import TestSuiteTable from './test_suite_table.vue'; +import TestSummary from './test_summary.vue'; +import TestSummaryTable from './test_summary_table.vue'; +import store from '~/pipelines/stores/test_reports'; + +export default { + name: 'TestReports', + components: { + GlLoadingIcon, + TestSuiteTable, + TestSummary, + TestSummaryTable, + }, + store, + computed: { + ...mapState(['isLoading', 'selectedSuite', 'testReports']), + showSuite() { + return this.selectedSuite.total_count > 0; + }, + showTests() { + return this.testReports.total_count > 0; + }, + }, + methods: { + ...mapActions(['setSelectedSuite', 'removeSelectedSuite']), + summaryBackClick() { + this.removeSelectedSuite(); + }, + summaryTableRowClick(suite) { + this.setSelectedSuite(suite); + }, + beforeEnterTransition() { + document.documentElement.style.overflowX = 'hidden'; + }, + afterLeaveTransition() { + document.documentElement.style.overflowX = ''; + }, + }, +}; +</script> + +<template> + <div v-if="isLoading"> + <gl-loading-icon size="lg" class="prepend-top-default js-loading-spinner" /> + </div> + + <div + v-else-if="!isLoading && showTests" + ref="container" + class="tests-detail position-relative js-tests-detail" + > + <transition + name="slide" + @before-enter="beforeEnterTransition" + @after-leave="afterLeaveTransition" + > + <div v-if="showSuite" key="detail" class="w-100 position-absolute slide-enter-to-element"> + <test-summary :report="selectedSuite" show-back @on-back-click="summaryBackClick" /> + + <test-suite-table /> + </div> + + <div v-else key="summary" class="w-100 position-absolute slide-enter-from-element"> + <test-summary :report="testReports" /> + + <test-summary-table @row-click="summaryTableRowClick" /> + </div> + </transition> + </div> + + <div v-else> + <div class="row prepend-top-default"> + <div class="col-12"> + <p class="js-no-tests-to-show">{{ s__('TestReports|There are no tests to show.') }}</p> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue new file mode 100644 index 00000000000..28b2c706320 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -0,0 +1,108 @@ +<script> +import { mapGetters } from 'vuex'; +import Icon from '~/vue_shared/components/icon.vue'; +import store from '~/pipelines/stores/test_reports'; +import { __ } from '~/locale'; + +export default { + name: 'TestsSuiteTable', + components: { + Icon, + }, + store, + props: { + heading: { + type: String, + required: false, + default: __('Tests'), + }, + }, + computed: { + ...mapGetters(['getSuiteTests']), + hasSuites() { + return this.getSuiteTests.length > 0; + }, + }, +}; +</script> + +<template> + <div> + <div class="row prepend-top-default"> + <div class="col-12"> + <h4>{{ heading }}</h4> + </div> + </div> + + <div v-if="hasSuites" class="test-reports-table js-test-cases-table"> + <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold fgray"> + <div role="rowheader" class="table-section section-20"> + {{ __('Class') }} + </div> + <div role="rowheader" class="table-section section-20"> + {{ __('Name') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Status') }} + </div> + <div role="rowheader" class="table-section flex-grow-1"> + {{ __('Trace'), }} + </div> + <div role="rowheader" class="table-section section-10 text-right"> + {{ __('Duration') }} + </div> + </div> + + <div + v-for="(testCase, index) in getSuiteTests" + :key="index" + class="gl-responsive-table-row rounded align-items-md-start mt-sm-3 js-case-row" + > + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Class') }}</div> + <div class="table-mobile-content pr-md-1">{{ testCase.classname }}</div> + </div> + + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> + <div class="table-mobile-content">{{ testCase.name }}</div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> + <div class="table-mobile-content text-center"> + <div + class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center" + :class="`ci-status-icon-${testCase.status}`" + > + <icon :size="24" :name="testCase.icon" /> + </div> + </div> + </div> + + <div class="table-section flex-grow-1"> + <div role="rowheader" class="table-mobile-header">{{ __('Trace'), }}</div> + <div class="table-mobile-content"> + <pre + v-if="testCase.system_output" + class="build-trace build-trace-rounded text-left" + ><code class="bash p-0">{{testCase.system_output}}</code></pre> + </div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-right"> + {{ testCase.formattedTime }} + </div> + </div> + </div> + </div> + + <div v-else> + <p class="js-no-test-cases">{{ s__('TestReports|There are no test cases to display.') }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue new file mode 100644 index 00000000000..dce8b020d6f --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue @@ -0,0 +1,116 @@ +<script> +import { GlButton, GlLink, GlProgressBar } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; +import Icon from '~/vue_shared/components/icon.vue'; + +export default { + name: 'TestSummary', + components: { + GlButton, + GlLink, + GlProgressBar, + Icon, + }, + props: { + report: { + type: Object, + required: true, + }, + showBack: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + heading() { + return this.report.name || __('Summary'); + }, + successPercentage() { + return Math.round((this.report.success_count / this.report.total_count) * 100) || 0; + }, + formattedDuration() { + return formatTime(secondsToMilliseconds(this.report.total_time)); + }, + progressBarVariant() { + if (this.successPercentage < 33) { + return 'danger'; + } + + if (this.successPercentage >= 33 && this.successPercentage < 66) { + return 'warning'; + } + + if (this.successPercentage >= 66 && this.successPercentage < 90) { + return 'primary'; + } + + return 'success'; + }, + }, + methods: { + onBackClick() { + this.$emit('on-back-click'); + }, + }, +}; +</script> + +<template> + <div> + <div class="row"> + <div class="col-12 d-flex prepend-top-8 align-items-center"> + <gl-button + v-if="showBack" + size="sm" + class="append-right-default js-back-button" + @click="onBackClick" + > + <icon name="angle-left" /> + </gl-button> + + <h4>{{ heading }}</h4> + </div> + </div> + + <div class="row mt-2"> + <div class="col-4 col-md"> + <span class="js-total-tests">{{ + sprintf(s__('TestReports|%{count} jobs'), { count: report.total_count }) + }}</span> + </div> + + <div class="col-4 col-md text-center text-md-center"> + <span class="js-failed-tests">{{ + sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count }) + }}</span> + </div> + + <div class="col-4 col-md text-right text-md-center"> + <span class="js-errored-tests">{{ + sprintf(s__('TestReports|%{count} errors'), { count: report.error_count }) + }}</span> + </div> + + <div class="col-6 mt-3 col-md mt-md-0 text-md-center"> + <span class="js-success-rate">{{ + sprintf(s__('TestReports|%{rate}%{sign} success rate'), { + rate: successPercentage, + sign: '%', + }) + }}</span> + </div> + + <div class="col-6 mt-3 col-md mt-md-0 text-right"> + <span class="js-duration">{{ formattedDuration }}</span> + </div> + </div> + + <div class="row mt-3"> + <div class="col-12"> + <gl-progress-bar :value="successPercentage" :variant="progressBarVariant" height="10px" /> + </div> + </div> + </div> +</template> 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 new file mode 100644 index 00000000000..96177512e35 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -0,0 +1,129 @@ +<script> +import { mapGetters } from 'vuex'; +import { s__ } from '~/locale'; +import store from '~/pipelines/stores/test_reports'; + +export default { + name: 'TestsSummaryTable', + store, + props: { + heading: { + type: String, + required: false, + default: s__('TestReports|Test suites'), + }, + }, + computed: { + ...mapGetters(['getTestSuites']), + hasSuites() { + return this.getTestSuites.length > 0; + }, + }, + methods: { + tableRowClick(suite) { + this.$emit('row-click', suite); + }, + }, +}; +</script> + +<template> + <div> + <div class="row prepend-top-default"> + <div class="col-12"> + <h4>{{ heading }}</h4> + </div> + </div> + + <div v-if="hasSuites" class="test-reports-table js-test-suites-table"> + <div role="row" class="gl-responsive-table-row table-row-header font-weight-bold"> + <div role="rowheader" class="table-section section-25 pl-3"> + {{ __('Suite') }} + </div> + <div role="rowheader" class="table-section section-25"> + {{ __('Duration') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Failed') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Errors'), }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Skipped'), }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Passed'), }} + </div> + <div role="rowheader" class="table-section section-10 pr-3 text-right"> + {{ __('Total') }} + </div> + </div> + + <div + 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" + @click="tableRowClick(testSuite)" + > + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Suite') }} + </div> + <div class="table-mobile-content underline cgray pl-3"> + {{ testSuite.name }} + </div> + </div> + + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content text-md-left"> + {{ testSuite.formattedTime }} + </div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Failed') }} + </div> + <div class="table-mobile-content">{{ testSuite.failed_count }}</div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Errors') }} + </div> + <div class="table-mobile-content">{{ testSuite.error_count }}</div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Skipped') }} + </div> + <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> + </div> + + <div class="table-section section-10 text-center"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Passed') }} + </div> + <div class="table-mobile-content">{{ testSuite.success_count }}</div> + </div> + + <div class="table-section section-10 text-right pr-md-3"> + <div role="rowheader" class="table-mobile-header font-weight-bold"> + {{ __('Total') }} + </div> + <div class="table-mobile-content">{{ testSuite.total_count }}</div> + </div> + </div> + </div> + + <div v-else> + <p class="js-no-tests-suites">{{ s__('TestReports|There are no test suites to show.') }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index d27829db50c..c9655d18a04 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -1,3 +1,9 @@ export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const PIPELINES_TABLE = 'PIPELINES_TABLE'; export const LAYOUT_CHANGE_DELAY = 300; + +export const TestStatus = { + FAILED: 'failed', + SKIPPED: 'skipped', + SUCCESS: 'success', +}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index b6f8716d37d..d8dbc3c2454 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -7,6 +7,8 @@ import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; import eventHub from './event_hub'; +import TestReports from './components/test_reports/test_reports.vue'; +import testReportsStore from './stores/test_reports'; Vue.use(Translate); @@ -17,7 +19,7 @@ export default () => { mediator.fetchPipeline(); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-graph-vue', components: { @@ -47,7 +49,7 @@ export default () => { }, }); - // eslint-disable-next-line + // eslint-disable-next-line no-new new Vue({ el: '#js-pipeline-header-vue', components: { @@ -81,4 +83,23 @@ export default () => { }); }, }); + + const testReportsEnabled = + window.gon && window.gon.features && window.gon.features.junitPipelineView; + + if (testReportsEnabled) { + testReportsStore.dispatch('setEndpoint', dataset.testReportEndpoint); + testReportsStore.dispatch('fetchReports'); + + // eslint-disable-next-line no-new + new Vue({ + el: '#js-pipeline-tests-detail', + components: { + TestReports, + }, + render(createElement) { + return createElement('test-reports'); + }, + }); + } }; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js new file mode 100644 index 00000000000..71d875c1a83 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -0,0 +1,30 @@ +import axios from '~/lib/utils/axios_utils'; +import * as types from './mutation_types'; +import createFlash from '~/flash'; +import { s__ } from '~/locale'; + +export const setEndpoint = ({ commit }, data) => commit(types.SET_ENDPOINT, data); + +export const fetchReports = ({ state, commit, dispatch }) => { + dispatch('toggleLoading'); + + return axios + .get(state.endpoint) + .then(response => { + const { data } = response; + commit(types.SET_REPORTS, data); + }) + .catch(() => { + createFlash(s__('TestReports|There was an error fetching the test reports.')); + }) + .finally(() => { + dispatch('toggleLoading'); + }); +}; + +export const setSelectedSuite = ({ commit }, data) => commit(types.SET_SELECTED_SUITE, data); +export const removeSelectedSuite = ({ commit }) => commit(types.SET_SELECTED_SUITE, {}); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js new file mode 100644 index 00000000000..788c1d32987 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -0,0 +1,23 @@ +import { addIconStatus, formattedTime, sortTestCases } from './utils'; + +export const getTestSuites = state => { + const { test_suites: testSuites = [] } = state.testReports; + + return testSuites.map(suite => ({ + ...suite, + formattedTime: formattedTime(suite.total_time), + })); +}; + +export const getSuiteTests = state => { + const { selectedSuite } = state; + + if (selectedSuite.test_cases) { + return selectedSuite.test_cases.sort(sortTestCases).map(addIconStatus); + } + + return []; +}; + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js new file mode 100644 index 00000000000..318dff5bcb2 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + actions, + getters, + mutations, + state, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js new file mode 100644 index 00000000000..832e45cf7a1 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_ENDPOINT = 'SET_ENDPOINT'; +export const SET_REPORTS = 'SET_REPORTS'; +export const SET_SELECTED_SUITE = 'SET_SELECTED_SUITE'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js new file mode 100644 index 00000000000..349e6ec0469 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -0,0 +1,19 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_ENDPOINT](state, endpoint) { + Object.assign(state, { endpoint }); + }, + + [types.SET_REPORTS](state, testReports) { + Object.assign(state, { testReports }); + }, + + [types.SET_SELECTED_SUITE](state, selectedSuite) { + Object.assign(state, { selectedSuite }); + }, + + [types.TOGGLE_LOADING](state) { + Object.assign(state, { isLoading: !state.isLoading }); + }, +}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js new file mode 100644 index 00000000000..80a0c2a46a0 --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -0,0 +1,6 @@ +export default () => ({ + endpoint: '', + testReports: {}, + selectedSuite: {}, + isLoading: false, +}); diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js new file mode 100644 index 00000000000..95466587d6b --- /dev/null +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -0,0 +1,36 @@ +import { TestStatus } from '~/pipelines/constants'; +import { formatTime, secondsToMilliseconds } from '~/lib/utils/datetime_utility'; + +function iconForTestStatus(status) { + switch (status) { + case 'success': + return 'status_success_borderless'; + case 'failed': + return 'status_failed_borderless'; + default: + return 'status_skipped_borderless'; + } +} + +export const formattedTime = timeInSeconds => formatTime(secondsToMilliseconds(timeInSeconds)); + +export const addIconStatus = testCase => ({ + ...testCase, + icon: iconForTestStatus(testCase.status), + formattedTime: formattedTime(testCase.execution_time), +}); + +export const sortTestCases = (a, b) => { + if (a.status === b.status) { + return 0; + } + + switch (b.status) { + case TestStatus.SUCCESS: + return -1; + case TestStatus.FAILED: + return 1; + default: + return 0; + } +}; diff --git a/app/assets/javascripts/privacy_policy_update_callout.js b/app/assets/javascripts/privacy_policy_update_callout.js deleted file mode 100644 index 97f41deb30f..00000000000 --- a/app/assets/javascripts/privacy_policy_update_callout.js +++ /dev/null @@ -1,8 +0,0 @@ -import PersistentUserCallout from '~/persistent_user_callout'; - -function initPrivacyPolicyUpdateCallout() { - const callout = document.querySelector('.js-privacy-policy-update'); - PersistentUserCallout.factory(callout); -} - -export default initPrivacyPolicyUpdateCallout; diff --git a/app/assets/javascripts/profile/gl_crop.js b/app/assets/javascripts/profile/gl_crop.js index 44bc2d9f5f8..880e1a88975 100644 --- a/app/assets/javascripts/profile/gl_crop.js +++ b/app/assets/javascripts/profile/gl_crop.js @@ -1,4 +1,4 @@ -/* eslint-disable no-useless-escape, no-var, no-underscore-dangle, func-names, no-return-assign, one-var, consistent-return, class-methods-use-this */ +/* eslint-disable no-useless-escape, no-underscore-dangle, func-names, no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; import 'cropper'; @@ -59,8 +59,7 @@ import _ from 'underscore'; } bindEvents() { - var _this; - _this = this; + const _this = this; this.fileInput.on('change', function(e) { _this.onFileInputChange(e, this); this.value = null; @@ -70,8 +69,7 @@ import _ from 'underscore'; this.modalCrop.on('hidden.bs.modal', this.onModalHide); this.uploadImageBtn.on('click', this.onUploadImageBtnClick); this.cropActionsBtn.on('click', function() { - var btn; - btn = this; + const btn = this; return _this.onActionBtnClick(btn); }); return (this.croppedImageBlob = null); @@ -82,8 +80,7 @@ import _ from 'underscore'; } onModalShow() { - var _this; - _this = this; + const _this = this; return this.modalCropImg.cropper({ viewMode: 1, center: false, @@ -128,8 +125,7 @@ import _ from 'underscore'; } onActionBtnClick(btn) { - var data; - data = $(btn).data(); + const data = $(btn).data(); if (this.modalCropImg.data('cropper') && data.method) { return this.modalCropImg.cropper(data.method, data.option); } @@ -140,9 +136,8 @@ import _ from 'underscore'; } readFile(input) { - var _this, reader; - _this = this; - reader = new FileReader(); + const _this = this; + const reader = new FileReader(); reader.onload = () => { _this.modalCropImg.attr('src', reader.result); return _this.modalCrop.modal('show'); @@ -151,9 +146,10 @@ import _ from 'underscore'; } dataURLtoBlob(dataURL) { - var array, binary, i, len; - binary = atob(dataURL.split(',')[1]); - array = []; + let i = 0; + let len = 0; + const binary = atob(dataURL.split(',')[1]); + const array = []; for (i = 0, len = binary.length; i < len; i += 1) { array.push(binary.charCodeAt(i)); @@ -164,9 +160,8 @@ import _ from 'underscore'; } setPreview() { - var filename; + const filename = this.fileInput.val().replace(FILENAMEREGEX, ''); this.previewImage.attr('src', this.dataURL); - filename = this.fileInput.val().replace(FILENAMEREGEX, ''); return this.filename.text(filename); } diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index 2c375b39c1f..031c54d2336 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -1,16 +1,20 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, no-cond-assign, no-return-assign */ +/* eslint-disable func-names, consistent-return, no-return-assign */ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import axios from '~/lib/utils/axios_utils'; import flash from '~/flash'; import { __ } from '~/locale'; +import sanitize from 'sanitize-html'; // highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> ) const highlighter = function(element, text, matches) { - var j, lastIndex, len, matchIndex, matchedChars, unmatched; - lastIndex = 0; - matchedChars = []; + let j = 0; + let len = 0; + let lastIndex = 0; + let matchedChars = []; + let matchIndex = matches[j]; + let unmatched = text.substring(lastIndex, matchIndex); for (j = 0, len = matches.length; j < len; j += 1) { matchIndex = matches[j]; unmatched = text.substring(lastIndex, matchIndex); @@ -54,10 +58,10 @@ export default class ProjectFindFile { 'keyup', (function(_this) { return function(event) { - var oldValue, ref, target, value; - target = $(event.target); - value = target.val(); - oldValue = (ref = target.data('oldValue')) != null ? ref : ''; + const target = $(event.target); + const value = target.val(); + const ref = target.data('oldValue'); + const oldValue = ref != null ? ref : ''; if (value !== oldValue) { target.data('oldValue', value); _this.findFile(); @@ -73,9 +77,8 @@ export default class ProjectFindFile { } findFile() { - var result, searchText; - searchText = this.inputElement.val(); - result = + const searchText = sanitize(this.inputElement.val()); + const result = searchText.length > 0 ? fuzzaldrinPlus.filter(this.filePaths, searchText) : this.filePaths; return this.renderList(result, searchText); // find file @@ -100,20 +103,21 @@ export default class ProjectFindFile { // render result renderList(filePaths, searchText) { - var blobItemUrl, filePath, html, i, len, matches, results; + let i = 0; + let len = 0; + let matches = []; + const results = []; this.element.find('.tree-table > tbody').empty(); - results = []; - for (i = 0, len = filePaths.length; i < len; i += 1) { - filePath = filePaths[i]; + const filePath = filePaths[i]; if (i === 20) { break; } if (searchText) { matches = fuzzaldrinPlus.match(filePath, searchText); } - blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; - html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); + const blobItemUrl = `${this.options.blobUrlTemplate}/${encodeURIComponent(filePath)}`; + const html = ProjectFindFile.makeHtml(filePath, matches, blobItemUrl); results.push(this.element.find('.tree-table > tbody').append(html)); } @@ -124,8 +128,7 @@ export default class ProjectFindFile { // make tbody row html static makeHtml(filePath, matches, blobItemUrl) { - var $tr; - $tr = $( + const $tr = $( "<tr class='tree-item'><td class='tree-item-file-name link-container'><a><i class='fa fa-file-text-o fa-fw'></i><span class='str-truncated'></span></a></td></tr>", ); if (matches) { @@ -140,9 +143,9 @@ export default class ProjectFindFile { } selectRow(type) { - var next, rows, selectedRow; - rows = this.element.find('.files-slider tr.tree-item'); - selectedRow = this.element.find('.files-slider tr.tree-item.selected'); + const rows = this.element.find('.files-slider tr.tree-item'); + let selectedRow = this.element.find('.files-slider tr.tree-item.selected'); + let next = selectedRow.prev(); if (rows && rows.length > 0) { if (selectedRow && selectedRow.length > 0) { if (type === 'UP') { @@ -174,7 +177,7 @@ export default class ProjectFindFile { } goToBlob() { - var $link = this.element.find('.tree-item.selected .tree-item-file-name a'); + const $link = this.element.find('.tree-item.selected .tree-item-file-name a'); if ($link.length) { $link.get(0).click(); diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 0fbb7e5ca42..66ce1ab5659 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, one-var, no-else-return */ +/* eslint-disable func-names, no-else-return */ import $ from 'jquery'; import Api from './api'; @@ -7,9 +7,11 @@ import { s__ } from './locale'; const projectSelect = () => { $('.ajax-project-select').each(function(i, select) { - var placeholder; + let placeholder; const simpleFilter = $(select).data('simpleFilter') || false; + const isInstantiated = $(select).data('select2'); this.groupId = $(select).data('groupId'); + this.userId = $(select).data('userId'); this.includeGroups = $(select).data('includeGroups'); this.allProjects = $(select).data('allProjects') || false; this.orderBy = $(select).data('orderBy') || 'id'; @@ -28,55 +30,62 @@ const projectSelect = () => { $(select).select2({ placeholder, minimumInputLength: 0, - query: (function(_this) { - return function(query) { - var finalCallback, projectsCallback; - finalCallback = function(projects) { - var data; - data = { - results: projects, - }; - return query.callback(data); + query: query => { + let projectsCallback; + const finalCallback = function(projects) { + const data = { + results: projects, }; - if (_this.includeGroups) { - projectsCallback = function(projects) { - var groupsCallback; - groupsCallback = function(groups) { - var data; - data = groups.concat(projects); - return finalCallback(data); - }; - return Api.groups(query.term, {}, groupsCallback); - }; - } else { - projectsCallback = finalCallback; - } - if (_this.groupId) { - return Api.groupProjects( - _this.groupId, - query.term, - { - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - with_shared: _this.withShared, - include_subgroups: _this.includeProjectsInSubgroups, - }, - projectsCallback, - ); - } else { - return Api.projects( - query.term, - { - order_by: _this.orderBy, - with_issues_enabled: _this.withIssuesEnabled, - with_merge_requests_enabled: _this.withMergeRequestsEnabled, - membership: !_this.allProjects, - }, - projectsCallback, - ); - } + return query.callback(data); }; - })(this), + if (this.includeGroups) { + projectsCallback = function(projects) { + const groupsCallback = function(groups) { + const data = groups.concat(projects); + return finalCallback(data); + }; + return Api.groups(query.term, {}, groupsCallback); + }; + } else { + projectsCallback = finalCallback; + } + if (this.groupId) { + return Api.groupProjects( + this.groupId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else if (this.userId) { + return Api.userProjects( + this.userId, + query.term, + { + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + with_shared: this.withShared, + include_subgroups: this.includeProjectsInSubgroups, + }, + projectsCallback, + ); + } else { + return Api.projects( + query.term, + { + order_by: this.orderBy, + with_issues_enabled: this.withIssuesEnabled, + with_merge_requests_enabled: this.withMergeRequestsEnabled, + membership: !this.allProjects, + }, + projectsCallback, + ); + } + }, id(project) { if (simpleFilter) return project.id; return JSON.stringify({ @@ -96,7 +105,7 @@ const projectSelect = () => { dropdownCssClass: 'ajax-project-dropdown', }); - if (simpleFilter) return select; + if (isInstantiated || simpleFilter) return select; return new ProjectSelectComboButton(select); }); }; diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index 9066844f687..2429da9c061 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -182,6 +182,10 @@ const bindEvents = () => { text: s__('ProjectTemplates|Netlify/Hexo'), icon: '.template-option .icon-netlify', }, + serverless_framework: { + text: s__('ProjectTemplates|Serverless Framework/JS'), + icon: '.template-option .icon-serverless_framework', + }, }; const selectedTemplate = templates[value]; diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue index 95f8270b5d0..5a6f9370564 100644 --- a/app/assets/javascripts/registry/components/collapsible_container.vue +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -8,12 +8,13 @@ import { GlModalDirective, GlEmptyState, } from '@gitlab/ui'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import Icon from '../../vue_shared/components/icon.vue'; +import createFlash from '~/flash'; +import Tracking from '~/tracking'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import TableRegistry from './table_registry.vue'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { __ } from '../../locale'; +import { DELETE_REPO_ERROR_MESSAGE } from '../constants'; +import { __ } from '~/locale'; export default { name: 'CollapsibeContainerRegisty', @@ -30,6 +31,7 @@ export default { GlTooltip: GlTooltipDirective, GlModal: GlModalDirective, }, + mixins: [Tracking.mixin({})], props: { repo: { type: Object, @@ -40,6 +42,10 @@ export default { return { isOpen: false, modalId: `confirm-repo-deletion-modal-${this.repo.id}`, + tracking: { + category: document.body.dataset.page, + label: 'registry_repository_delete', + }, }; }, computed: { @@ -61,15 +67,13 @@ export default { } }, handleDeleteRepository() { + this.track('confirm_delete', {}); return this.deleteItem(this.repo) .then(() => { createFlash(__('This container registry has been scheduled for deletion.'), 'notice'); this.fetchRepos(); }) - .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); - }, - showError(message) { - createFlash(errorMessages[message]); + .catch(() => createFlash(DELETE_REPO_ERROR_MESSAGE)); }, }, }; @@ -97,10 +101,9 @@ export default { v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove repository')" :aria-label="s__('ContainerRegistry|Remove repository')" - data-track-event="click_button" - data-track-label="registry_repository_delete" class="js-remove-repo btn-inverted" variant="danger" + @click="track('click_button', {})" > <icon name="remove" /> </gl-button> @@ -124,7 +127,13 @@ export default { class="mx-auto my-0" /> </div> - <gl-modal :modal-id="modalId" ok-variant="danger" @ok="handleDeleteRepository"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="handleDeleteRepository" + @cancel="track('cancel_delete', {})" + > <template v-slot:modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> <p v-html=" diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue index 8470fbc2b59..caa5fd4ff4e 100644 --- a/app/assets/javascripts/registry/components/table_registry.vue +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -1,20 +1,15 @@ <script> import { mapActions, mapGetters } from 'vuex'; -import { - GlButton, - GlFormCheckbox, - GlTooltipDirective, - GlModal, - GlModalDirective, -} from '@gitlab/ui'; -import { n__, s__, sprintf } from '../../locale'; -import createFlash from '../../flash'; -import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; -import TablePagination from '../../vue_shared/components/pagination/table_pagination.vue'; -import Icon from '../../vue_shared/components/icon.vue'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; -import { errorMessages, errorMessagesTypes } from '../constants'; -import { numberToHumanSize } from '../../lib/utils/number_utils'; +import { GlButton, GlFormCheckbox, GlTooltipDirective, GlModal } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { n__, s__, sprintf } from '~/locale'; +import createFlash from '~/flash'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { FETCH_REGISTRY_ERROR_MESSAGE, DELETE_REGISTRY_ERROR_MESSAGE } from '../constants'; export default { components: { @@ -27,7 +22,6 @@ export default { }, directives: { GlTooltip: GlTooltipDirective, - GlModal: GlModalDirective, }, mixins: [timeagoMixin], props: { @@ -65,12 +59,21 @@ export default { this.itemsToBeDeleted.length === 0 ? 1 : this.itemsToBeDeleted.length, ); }, - }, - mounted() { - this.$refs.deleteModal.$refs.modal.$on('hide', this.removeModalEvents); + isMultiDelete() { + return this.itemsToBeDeleted.length > 1; + }, + tracking() { + return { + property: this.repo.name, + label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, }, methods: { ...mapActions(['fetchList', 'deleteItem', 'multiDeleteItems']), + track(action) { + Tracking.event(document.body.dataset.page, action, this.tracking); + }, setModalDescription(itemIndex = -1) { if (itemIndex === -1) { this.modalDescription = sprintf( @@ -92,17 +95,11 @@ export default { formatSize(size) { return numberToHumanSize(size); }, - removeModalEvents() { - this.$refs.deleteModal.$refs.modal.$off('ok'); - }, deleteSingleItem(index) { this.setModalDescription(index); this.itemsToBeDeleted = [index]; - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleSingleDelete(this.repo.list[index]); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, deleteMultipleItems() { this.itemsToBeDeleted = [...this.selectedItems]; @@ -111,17 +108,14 @@ export default { } else if (this.selectedItems.length > 1) { this.setModalDescription(); } - - this.$refs.deleteModal.$refs.modal.$once('ok', () => { - this.removeModalEvents(); - this.handleMultipleDelete(); - }); + this.track('click_button'); + this.$refs.deleteModal.show(); }, handleSingleDelete(itemToDelete) { this.itemsToBeDeleted = []; this.deleteItem(itemToDelete) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); }, handleMultipleDelete() { const { itemsToBeDeleted } = this; @@ -134,19 +128,16 @@ export default { items: itemsToBeDeleted.map(x => this.repo.list[x].tag), }) .then(() => this.fetchList({ repo: this.repo })) - .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + .catch(() => createFlash(DELETE_REGISTRY_ERROR_MESSAGE)); } else { - this.showError(errorMessagesTypes.DELETE_REGISTRY); + createFlash(DELETE_REGISTRY_ERROR_MESSAGE); } }, onPageChange(pageNumber) { this.fetchList({ repo: this.repo, page: pageNumber }).catch(() => - this.showError(errorMessagesTypes.FETCH_REGISTRY), + createFlash(FETCH_REGISTRY_ERROR_MESSAGE), ); }, - showError(message) { - createFlash(errorMessages[message]); - }, onSelectAllChange() { if (this.selectAllChecked) { this.deselectAll(); @@ -179,6 +170,15 @@ export default { canDeleteRow(item) { return item && item.canDelete && !this.isDeleteDisabled; }, + onDeletionConfirmed() { + this.track('confirm_delete'); + if (this.isMultiDelete) { + this.handleMultipleDelete(); + } else { + const index = this.itemsToBeDeleted[0]; + this.handleSingleDelete(this.repo.list[index]); + } + }, }, }; </script> @@ -202,12 +202,10 @@ export default { <th> <gl-button v-if="canDeleteRepo" + ref="bulkDeleteButton" v-gl-tooltip - v-gl-modal="modalId" :disabled="!selectedItems || selectedItems.length === 0" - class="js-delete-registry float-right" - data-track-event="click_button" - data-track-label="bulk_registry_tag_delete" + class="float-right" variant="danger" :title="s__('ContainerRegistry|Remove selected tags')" :aria-label="s__('ContainerRegistry|Remove selected tags')" @@ -259,11 +257,8 @@ export default { <td class="content action-buttons"> <gl-button v-if="canDeleteRow(item)" - v-gl-modal="modalId" :title="s__('ContainerRegistry|Remove tag')" :aria-label="s__('ContainerRegistry|Remove tag')" - data-track-event="click_button" - data-track-label="registry_tag_delete" variant="danger" class="js-delete-registry-row float-right btn-inverted btn-border-color btn-icon" @click="deleteSingleItem(index)" @@ -282,7 +277,13 @@ export default { class="js-registry-pagination" /> - <gl-modal ref="deleteModal" :modal-id="modalId" ok-variant="danger"> + <gl-modal + ref="deleteModal" + :modal-id="modalId" + ok-variant="danger" + @ok="onDeletionConfirmed" + @cancel="track('cancel_delete')" + > <template v-slot:modal-title>{{ modalAction }}</template> <template v-slot:modal-ok>{{ modalAction }}</template> <p v-html="modalDescription"></p> diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js index 712b0fade3d..db798fb88ac 100644 --- a/app/assets/javascripts/registry/constants.js +++ b/app/assets/javascripts/registry/constants.js @@ -1,15 +1,8 @@ import { __ } from '../locale'; -export const errorMessagesTypes = { - FETCH_REGISTRY: 'FETCH_REGISTRY', - FETCH_REPOS: 'FETCH_REPOS', - DELETE_REPO: 'DELETE_REPO', - DELETE_REGISTRY: 'DELETE_REGISTRY', -}; - -export const errorMessages = { - [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'), - [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'), - [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'), - [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'), -}; +export const FETCH_REGISTRY_ERROR_MESSAGE = __( + 'Something went wrong while fetching the registry list.', +); +export const FETCH_REPOS_ERROR_MESSAGE = __('Something went wrong while fetching the projects.'); +export const DELETE_REPO_ERROR_MESSAGE = __('Something went wrong on our end.'); +export const DELETE_REGISTRY_ERROR_MESSAGE = __('Something went wrong on our end.'); diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js index 2121f518a7a..6afba618486 100644 --- a/app/assets/javascripts/registry/stores/actions.js +++ b/app/assets/javascripts/registry/stores/actions.js @@ -1,7 +1,7 @@ import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import * as types from './mutation_types'; -import { errorMessages, errorMessagesTypes } from '../constants'; +import { FETCH_REPOS_ERROR_MESSAGE, FETCH_REGISTRY_ERROR_MESSAGE } from '../constants'; export const fetchRepos = ({ commit, state }) => { commit(types.TOGGLE_MAIN_LOADING); @@ -14,7 +14,7 @@ export const fetchRepos = ({ commit, state }) => { }) .catch(() => { commit(types.TOGGLE_MAIN_LOADING); - createFlash(errorMessages[errorMessagesTypes.FETCH_REPOS]); + createFlash(FETCH_REPOS_ERROR_MESSAGE); }); }; @@ -30,7 +30,7 @@ export const fetchList = ({ commit }, { repo, page }) => { }) .catch(() => { commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); - createFlash(errorMessages[errorMessagesTypes.FETCH_REGISTRY]); + createFlash(FETCH_REGISTRY_ERROR_MESSAGE); }); }; diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js index ea5925247d1..419de848883 100644 --- a/app/assets/javascripts/registry/stores/mutations.js +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -1,33 +1,31 @@ import * as types from './mutation_types'; -import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; export default { [types.SET_MAIN_ENDPOINT](state, endpoint) { - Object.assign(state, { endpoint }); + state.endpoint = endpoint; }, [types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) { - Object.assign(state, { isDeleteDisabled }); + state.isDeleteDisabled = isDeleteDisabled; }, [types.SET_REPOS_LIST](state, list) { - Object.assign(state, { - repos: list.map(el => ({ - canDelete: Boolean(el.destroy_path), - destroyPath: el.destroy_path, - id: el.id, - isLoading: false, - list: [], - location: el.location, - name: el.path, - tagsPath: el.tags_path, - projectId: el.project_id, - })), - }); + state.repos = list.map(el => ({ + canDelete: Boolean(el.destroy_path), + destroyPath: el.destroy_path, + id: el.id, + isLoading: false, + list: [], + location: el.location, + name: el.path, + tagsPath: el.tags_path, + projectId: el.project_id, + })); }, [types.TOGGLE_MAIN_LOADING](state) { - Object.assign(state, { isLoading: !state.isLoading }); + state.isLoading = !state.isLoading; }, [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { diff --git a/app/assets/javascripts/releases/detail/components/app.vue b/app/assets/javascripts/releases/detail/components/app.vue index 54a441de886..073cfcd7694 100644 --- a/app/assets/javascripts/releases/detail/components/app.vue +++ b/app/assets/javascripts/releases/detail/components/app.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; +import _ from 'underscore'; import { __, sprintf } from '~/locale'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; @@ -23,6 +24,7 @@ export default { 'markdownDocsPath', 'markdownPreviewPath', 'releasesPagePath', + 'updateReleaseApiDocsPath', ]), showForm() { return !this.isFetchingRelease && !this.fetchError; @@ -42,6 +44,20 @@ export default { tagName() { return this.$store.state.release.tagName; }, + tagNameHintText() { + return sprintf( + __( + 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', + ), + { + linkStart: `<a href="${_.escape( + this.updateReleaseApiDocsPath, + )}" target="_blank" rel="noopener noreferrer">`, + linkEnd: '</a>', + }, + false, + ); + }, releaseTitle: { get() { return this.$store.state.release.name; @@ -77,22 +93,22 @@ export default { <div class="d-flex flex-column"> <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> <form v-if="showForm" @submit.prevent="updateRelease()"> - <div class="row"> - <gl-form-group class="col-md-6 col-lg-5 col-xl-4"> - <label for="git-ref">{{ __('Tag name') }}</label> - <gl-form-input - id="git-ref" - v-model="tagName" - type="text" - class="form-control" - aria-describedby="tag-name-help" - disabled - /> - <div id="tag-name-help" class="form-text text-muted"> - {{ __('Choose an existing tag, or create a new one') }} + <gl-form-group> + <div class="row"> + <div class="col-md-6 col-lg-5 col-xl-4"> + <label for="git-ref">{{ __('Tag name') }}</label> + <gl-form-input + id="git-ref" + v-model="tagName" + type="text" + class="form-control" + aria-describedby="tag-name-help" + disabled + /> </div> - </gl-form-group> - </div> + </div> + <div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div> + </gl-form-group> <gl-form-group> <label for="release-title">{{ __('Release title') }}</label> <gl-form-input diff --git a/app/assets/javascripts/releases/detail/index.js b/app/assets/javascripts/releases/detail/index.js index 3da971e6d90..0dab90a1ede 100644 --- a/app/assets/javascripts/releases/detail/index.js +++ b/app/assets/javascripts/releases/detail/index.js @@ -5,7 +5,7 @@ import createStore from './store'; export default () => { const el = document.getElementById('js-edit-release-page'); - const store = createStore(el.dataset); + const store = createStore(); store.dispatch('setInitialState', el.dataset); return new Vue({ diff --git a/app/assets/javascripts/releases/detail/store/state.js b/app/assets/javascripts/releases/detail/store/state.js index ff98e2bed78..7e3d975f1ae 100644 --- a/app/assets/javascripts/releases/detail/store/state.js +++ b/app/assets/javascripts/releases/detail/store/state.js @@ -4,6 +4,7 @@ export default () => ({ releasesPagePath: null, markdownDocsPath: null, markdownPreviewPath: null, + updateReleaseApiDocsPath: null, release: null, diff --git a/app/assets/javascripts/releases/list/components/release_block.vue b/app/assets/javascripts/releases/list/components/release_block.vue index 8d4b32e9dc0..2b6aa6aeff9 100644 --- a/app/assets/javascripts/releases/list/components/release_block.vue +++ b/app/assets/javascripts/releases/list/components/release_block.vue @@ -10,6 +10,7 @@ import { slugify } from '~/lib/utils/text_utility'; import { getLocationHash } from '~/lib/utils/url_utility'; import { scrollToElement } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import ReleaseBlockFooter from './release_block_footer.vue'; export default { name: 'ReleaseBlock', @@ -19,6 +20,7 @@ export default { GlButton, Icon, UserAvatarLink, + ReleaseBlockFooter, }, directives: { GlTooltip: GlTooltipDirective, @@ -76,9 +78,12 @@ export default { }, shouldShowEditButton() { return Boolean( - this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit, + this.glFeatures.releaseEditPage && this.release._links && this.release._links.edit_url, ); }, + shouldShowFooter() { + return this.glFeatures.releaseIssueSummary; + }, }, mounted() { const hash = getLocationHash(); @@ -108,7 +113,7 @@ export default { v-gl-tooltip class="btn btn-default js-edit-button ml-2" :title="__('Edit this release')" - :href="release._links.edit" + :href="release._links.edit_url" > <icon name="pencil" /> </gl-link> @@ -164,7 +169,7 @@ export default { by <user-avatar-link class="prepend-left-4" - :link-href="author.path" + :link-href="author.web_url" :img-src="author.avatar_url" :img-alt="userImageAltDescription" :tooltip-text="author.username" @@ -216,5 +221,16 @@ export default { <div v-html="release.description_html"></div> </div> </div> + + <release-block-footer + v-if="shouldShowFooter" + class="card-footer" + :commit="release.commit" + :commit-path="release.commit_path" + :tag-name="release.tag_name" + :tag-path="release.tag_path" + :author="release.author" + :released-at="release.released_at" + /> </div> </template> diff --git a/app/assets/javascripts/releases/list/components/release_block_footer.vue b/app/assets/javascripts/releases/list/components/release_block_footer.vue new file mode 100644 index 00000000000..5659f0e530b --- /dev/null +++ b/app/assets/javascripts/releases/list/components/release_block_footer.vue @@ -0,0 +1,112 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { __, sprintf } from '~/locale'; + +export default { + name: 'ReleaseBlockFooter', + components: { + Icon, + GlLink, + UserAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + commit: { + type: Object, + required: false, + default: null, + }, + commitPath: { + type: String, + required: false, + default: '', + }, + tagName: { + type: String, + required: false, + default: '', + }, + tagPath: { + type: String, + required: false, + default: '', + }, + author: { + type: Object, + required: false, + default: null, + }, + releasedAt: { + type: String, + required: false, + default: '', + }, + }, + computed: { + releasedAtTimeAgo() { + return this.timeFormated(this.releasedAt); + }, + userImageAltDescription() { + return this.author && this.author.username + ? sprintf(__("%{username}'s avatar"), { username: this.author.username }) + : null; + }, + }, +}; +</script> +<template> + <div> + <div v-if="commit" class="float-left mr-3 d-flex align-items-center js-commit-info"> + <icon ref="commitIcon" name="commit" class="mr-1" /> + <div v-gl-tooltip.bottom :title="commit.title"> + <gl-link v-if="commitPath" :href="commitPath"> + {{ commit.short_id }} + </gl-link> + <span v-else>{{ commit.short_id }}</span> + </div> + </div> + + <div v-if="tagName" class="float-left mr-3 d-flex align-items-center js-tag-info"> + <icon name="tag" class="mr-1" /> + <div v-gl-tooltip.bottom :title="__('Tag')"> + <gl-link v-if="tagPath" :href="tagPath"> + {{ tagName }} + </gl-link> + <span v-else>{{ tagName }}</span> + </div> + </div> + + <div + v-if="releasedAt || author" + class="float-left d-flex align-items-center js-author-date-info" + > + <span class="text-secondary">{{ __('Created') }} </span> + <template v-if="releasedAt"> + <span + v-gl-tooltip.bottom + :title="tooltipTitle(releasedAt)" + class="text-secondary flex-shrink-0" + > + {{ releasedAtTimeAgo }} + </span> + </template> + + <div v-if="author" class="d-flex"> + <span class="text-secondary">{{ __('by') }} </span> + <user-avatar-link + :link-href="author.web_url" + :img-src="author.avatar_url" + :img-alt="userImageAltDescription" + :tooltip-text="author.username" + tooltip-placement="bottom" + /> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/reports/components/issue_status_icon.vue b/app/assets/javascripts/reports/components/issue_status_icon.vue index 386653b9444..62a9338b864 100644 --- a/app/assets/javascripts/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/reports/components/issue_status_icon.vue @@ -50,6 +50,6 @@ export default { }" class="report-block-list-icon" > - <icon :name="iconName" :size="statusIconSize" /> + <icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" /> </div> </template> diff --git a/app/assets/javascripts/reports/components/report_item.vue b/app/assets/javascripts/reports/components/report_item.vue index f3f7d2648a8..3c8a9e6ebef 100644 --- a/app/assets/javascripts/reports/components/report_item.vue +++ b/app/assets/javascripts/reports/components/report_item.vue @@ -46,6 +46,7 @@ export default { <li :class="{ 'is-dismissed': issue.isDismissed }" class="report-block-list-issue align-items-center" + data-qa-selector="report_item_row" > <issue-status-icon v-if="showReportSectionStatusIcon" diff --git a/app/assets/javascripts/repository/components/directory_download_links.vue b/app/assets/javascripts/repository/components/directory_download_links.vue new file mode 100644 index 00000000000..dffadade082 --- /dev/null +++ b/app/assets/javascripts/repository/components/directory_download_links.vue @@ -0,0 +1,47 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + currentPath: { + type: String, + required: false, + default: null, + }, + links: { + type: Array, + required: true, + }, + }, + computed: { + normalizedLinks() { + return this.links.map(link => ({ + text: link.text, + path: `${link.path}?path=${this.currentPath}`, + })); + }, + }, +}; +</script> + +<template> + <section class="border-top pt-1 mt-1"> + <h5 class="m-0 dropdown-bold-header">{{ __('Download this directory') }}</h5> + <div class="dropdown-menu-content"> + <div class="btn-group ml-0 w-100"> + <gl-link + v-for="(link, index) in normalizedLinks" + :key="index" + :href="link.path" + :class="{ 'btn-primary': index === 0 }" + class="btn btn-xs" + > + {{ link.text }} + </gl-link> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 19a2db2db25..70678b0db37 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui'; +import defaultAvatarUrl from 'images/no_avatar.png'; import { sprintf, s__ } from '~/locale'; import Icon from '../../vue_shared/components/icon.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -38,7 +39,14 @@ export default { path: this.currentPath.replace(/^\//, ''), }; }, - update: data => data.project.repository.tree.lastCommit, + update: data => { + const pipelines = data.project.repository.tree.lastCommit.pipelines.edges; + + return { + ...data.project.repository.tree.lastCommit, + pipeline: pipelines.length && pipelines[0].node, + }; + }, context: { isSingleRequest: true, }, @@ -61,7 +69,7 @@ export default { computed: { statusTitle() { return sprintf(s__('Commits|Commit: %{commitText}'), { - commitText: this.commit.latestPipeline.detailedStatus.text, + commitText: this.commit.pipeline.detailedStatus.text, }); }, isLoading() { @@ -76,12 +84,13 @@ export default { this.showDescription = !this.showDescription; }, }, + defaultAvatarUrl, }; </script> <template> <div class="info-well d-none d-sm-flex project-last-commit commit p-3"> - <gl-loading-icon v-if="isLoading" size="md" class="mx-auto" /> + <gl-loading-icon v-if="isLoading" size="md" class="m-auto" /> <template v-else> <user-avatar-link v-if="commit.author" @@ -90,6 +99,9 @@ export default { :img-size="40" class="avatar-cell" /> + <span v-else class="avatar-cell user-avatar-link"> + <img :src="$options.defaultAvatarUrl" width="40" height="40" class="avatar s40" /> + </span> <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> <gl-link :href="commit.webUrl" class="commit-row-message item-title"> @@ -102,7 +114,7 @@ export default { class="text-expander" @click="toggleShowDescription" > - <icon name="ellipsis_h" /> + <icon name="ellipsis_h" :size="10" /> </gl-button> <div class="committer"> <gl-link @@ -112,12 +124,15 @@ export default { > {{ commit.author.name }} </gl-link> + <template v-else> + {{ commit.authorName }} + </template> {{ s__('LastCommit|authored') }} <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> </div> <pre v-if="commit.description" - v-show="showDescription" + :class="{ 'd-block': showDescription }" class="commit-row-description append-bottom-8" > {{ commit.description }} @@ -125,19 +140,20 @@ export default { </div> <div class="commit-actions flex-row"> <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div> - <gl-link - v-if="commit.latestPipeline" - v-gl-tooltip - :href="commit.latestPipeline.detailedStatus.detailsPath" - :title="statusTitle" - class="js-commit-pipeline" - > - <ci-icon - :status="commit.latestPipeline.detailedStatus" - :size="24" - :aria-label="statusTitle" - /> - </gl-link> + <div v-if="commit.pipeline" class="ci-status-link"> + <gl-link + v-gl-tooltip.left + :href="commit.pipeline.detailedStatus.detailsPath" + :title="statusTitle" + class="js-commit-pipeline" + > + <ci-icon + :status="commit.pipeline.detailedStatus" + :size="24" + :aria-label="statusTitle" + /> + </gl-link> + </div> <div class="commit-sha-group d-flex"> <div class="label label-monospace monospace"> {{ showCommitId }} @@ -153,3 +169,9 @@ export default { </template> </div> </template> + +<style scoped> +.commit { + min-height: 4.75rem; +} +</style> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue new file mode 100644 index 00000000000..7f974838359 --- /dev/null +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -0,0 +1,49 @@ +<script> +import { GlLink, GlLoadingIcon } from '@gitlab/ui'; +import getReadmeQuery from '../../queries/getReadme.query.graphql'; + +export default { + apollo: { + readme: { + query: getReadmeQuery, + variables() { + return { + url: this.blob.webUrl, + }; + }, + loadingKey: 'loading', + }, + }, + components: { + GlLink, + GlLoadingIcon, + }, + props: { + blob: { + type: Object, + required: true, + }, + }, + data() { + return { + readme: null, + loading: 0, + }; + }, +}; +</script> + +<template> + <article class="file-holder limited-width-container readme-holder"> + <div class="file-title"> + <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> + <gl-link :href="blob.webUrl"> + <strong>{{ blob.name }}</strong> + </gl-link> + </div> + <div class="blob-viewer"> + <gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" /> + <div v-else-if="readme" v-html="readme.html"></div> + </div> + </article> +</template> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 610c7e8d99e..8f2e9264bca 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -1,19 +1,15 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, __ } from '../../../locale'; import getRefMixin from '../../mixins/get_ref'; -import getFiles from '../../queries/getFiles.query.graphql'; import getProjectPath from '../../queries/getProjectPath.query.graphql'; import TableHeader from './header.vue'; import TableRow from './row.vue'; import ParentRow from './parent_row.vue'; -const PAGE_SIZE = 100; - export default { components: { - GlLoadingIcon, + GlSkeletonLoading, TableHeader, TableRow, ParentRow, @@ -29,86 +25,39 @@ export default { type: String, required: true, }, + entries: { + type: Object, + required: false, + default: () => ({}), + }, + isLoading: { + type: Boolean, + required: true, + }, }, data() { return { projectPath: '', - nextPageCursor: '', - entries: { - trees: [], - submodules: [], - blobs: [], - }, - isLoadingFiles: false, }; }, computed: { tableCaption() { + if (this.isLoading) { + return sprintf( + __( + 'Loading files, directories, and submodules in the path %{path} for commit reference %{ref}', + ), + { path: this.path, ref: this.ref }, + ); + } + return sprintf( __('Files, directories, and submodules in the path %{path} for commit reference %{ref}'), { path: this.path, ref: this.ref }, ); }, showParentRow() { - return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1; - }, - }, - watch: { - $route: function routeChange() { - this.entries.trees = []; - this.entries.submodules = []; - this.entries.blobs = []; - this.nextPageCursor = ''; - this.fetchFiles(); - }, - }, - mounted() { - // We need to wait for `ref` and `projectPath` to be set - this.$nextTick(() => this.fetchFiles()); - }, - methods: { - fetchFiles() { - this.isLoadingFiles = true; - - return this.$apollo - .query({ - query: getFiles, - variables: { - projectPath: this.projectPath, - ref: this.ref, - path: this.path || '/', - nextPageCursor: this.nextPageCursor, - pageSize: PAGE_SIZE, - }, - }) - .then(({ data }) => { - if (!data) return; - - const pageInfo = this.hasNextPage(data.project.repository.tree); - - this.isLoadingFiles = false; - this.entries = Object.keys(this.entries).reduce( - (acc, key) => ({ - ...acc, - [key]: this.normalizeData(key, data.project.repository.tree[key].edges), - }), - {}, - ); - - if (pageInfo && pageInfo.hasNextPage) { - this.nextPageCursor = pageInfo.endCursor; - this.fetchFiles(); - } - }) - .catch(() => createFlash(__('An error occurred while fetching folder content.'))); - }, - normalizeData(key, data) { - return this.entries[key].concat(data.map(({ node }) => node)); - }, - hasNextPage(data) { - return [] - .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) - .find(({ hasNextPage }) => hasNextPage); + return !this.isLoading && ['', '/'].indexOf(this.path) === -1; }, }, }; @@ -117,12 +66,7 @@ export default { <template> <div class="tree-content-holder"> <div class="table-holder bordered-box"> - <table class="table tree-table qa-file-tree" aria-live="polite"> - <caption class="sr-only"> - {{ - tableCaption - }} - </caption> + <table :aria-label="tableCaption" class="table tree-table qa-file-tree" aria-live="polite"> <table-header v-once /> <tbody> <parent-row v-show="showParentRow" :commit-ref="ref" :path="path" /> @@ -131,6 +75,7 @@ export default { v-for="entry in val" :id="entry.id" :key="`${entry.flatPath}-${entry.id}`" + :sha="entry.sha" :project-path="projectPath" :current-path="path" :name="entry.name" @@ -141,9 +86,15 @@ export default { :lfs-oid="entry.lfsOid" /> </template> + <template v-if="isLoading"> + <tr v-for="i in 5" :key="i" aria-hidden="true"> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="h-auto" /></td> + <td><gl-skeleton-loading :lines="1" class="ml-auto h-auto w-50" /></td> + </tr> + </template> </tbody> </table> - <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" /> </div> </div> </template> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 171841178a3..cf0457a2abf 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -1,7 +1,8 @@ <script> -import { GlBadge, GlLink, GlSkeletonLoading } from '@gitlab/ui'; +import { GlBadge, GlLink, GlSkeletonLoading, GlTooltipDirective } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import Icon from '~/vue_shared/components/icon.vue'; import { getIconName } from '../../utils/icon'; import getRefMixin from '../../mixins/get_ref'; import getCommit from '../../queries/getCommit.query.graphql'; @@ -12,6 +13,10 @@ export default { GlLink, GlSkeletonLoading, TimeagoTooltip, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, }, apollo: { commit: { @@ -32,6 +37,10 @@ export default { type: String, required: true, }, + sha: { + type: String, + required: true, + }, projectPath: { type: String, required: true, @@ -93,15 +102,20 @@ export default { return this.path.replace(new RegExp(`^${this.currentPath}/`), ''); }, shortSha() { - return this.id.slice(0, 8); + return this.sha.slice(0, 8); + }, + hasLockLabel() { + return this.commit && this.commit.lockLabel; }, }, methods: { - openRow() { - if (this.isFolder) { + openRow(e) { + if (e.target.tagName === 'A') return; + + if (this.isFolder && !e.metaKey) { this.$router.push(this.routerLinkTo); } else { - visitUrl(this.url); + visitUrl(this.url, e.metaKey); } }, }, @@ -120,15 +134,28 @@ export default { <template v-if="isSubmodule"> @ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link> </template> + <icon + v-if="hasLockLabel" + v-gl-tooltip + :title="commit.lockLabel" + name="lock" + :size="12" + class="ml-2 vertical-align-middle" + /> </td> <td class="d-none d-sm-table-cell tree-commit"> - <gl-link v-if="commit" :href="commit.commitPath" class="str-truncated-100 tree-commit-link"> + <gl-link + v-if="commit" + :href="commit.commitPath" + :title="commit.message" + class="str-truncated-100 tree-commit-link" + > {{ commit.message }} </gl-link> <gl-skeleton-loading v-else :lines="1" class="h-auto" /> </td> <td class="tree-time-ago text-right"> - <timeago-tooltip v-if="commit" :time="commit.committedDate" tooltip-placement="bottom" /> + <timeago-tooltip v-if="commit" :time="commit.committedDate" /> <gl-skeleton-loading v-else :lines="1" class="ml-auto h-auto w-50" /> </td> </tr> diff --git a/app/assets/javascripts/repository/components/tree_action_link.vue b/app/assets/javascripts/repository/components/tree_action_link.vue new file mode 100644 index 00000000000..72764f3ccc9 --- /dev/null +++ b/app/assets/javascripts/repository/components/tree_action_link.vue @@ -0,0 +1,28 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + path: { + type: String, + required: true, + }, + text: { + type: String, + required: true, + }, + cssClass: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> + +<template> + <gl-link :href="path" :class="cssClass" class="btn">{{ text }}</gl-link> +</template> diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue new file mode 100644 index 00000000000..949e653fc8f --- /dev/null +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -0,0 +1,115 @@ +<script> +import createFlash from '~/flash'; +import { __ } from '../../locale'; +import FileTable from './table/index.vue'; +import getRefMixin from '../mixins/get_ref'; +import getFiles from '../queries/getFiles.query.graphql'; +import getProjectPath from '../queries/getProjectPath.query.graphql'; +import FilePreview from './preview/index.vue'; +import { readmeFile } from '../utils/readme'; + +const PAGE_SIZE = 100; + +export default { + components: { + FileTable, + FilePreview, + }, + mixins: [getRefMixin], + apollo: { + projectPath: { + query: getProjectPath, + }, + }, + props: { + path: { + type: String, + required: false, + default: '/', + }, + }, + data() { + return { + projectPath: '', + nextPageCursor: '', + entries: { + trees: [], + submodules: [], + blobs: [], + }, + isLoadingFiles: false, + }; + }, + computed: { + readme() { + return readmeFile(this.entries.blobs); + }, + }, + + watch: { + $route: function routeChange() { + this.entries.trees = []; + this.entries.submodules = []; + this.entries.blobs = []; + this.nextPageCursor = ''; + this.fetchFiles(); + }, + }, + mounted() { + // We need to wait for `ref` and `projectPath` to be set + this.$nextTick(() => this.fetchFiles()); + }, + methods: { + fetchFiles() { + this.isLoadingFiles = true; + + return this.$apollo + .query({ + query: getFiles, + variables: { + projectPath: this.projectPath, + ref: this.ref, + path: this.path || '/', + nextPageCursor: this.nextPageCursor, + pageSize: PAGE_SIZE, + }, + }) + .then(({ data }) => { + if (!data) return; + + const pageInfo = this.hasNextPage(data.project.repository.tree); + + this.isLoadingFiles = false; + this.entries = Object.keys(this.entries).reduce( + (acc, key) => ({ + ...acc, + [key]: this.normalizeData(key, data.project.repository.tree[key].edges), + }), + {}, + ); + + if (pageInfo && pageInfo.hasNextPage) { + this.nextPageCursor = pageInfo.endCursor; + this.fetchFiles(); + } + }) + .catch(() => createFlash(__('An error occurred while fetching folder content.'))); + }, + normalizeData(key, data) { + return this.entries[key].concat(data.map(({ node }) => node)); + }, + hasNextPage(data) { + return [] + .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) + .find(({ hasNextPage }) => hasNextPage); + }, + }, +}; +</script> + +<template> + <div> + <file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" /> + <file-preview v-if="readme" :blob="readme" /> + </div> +</template> diff --git a/app/assets/javascripts/repository/graphql.js b/app/assets/javascripts/repository/graphql.js index 6cb253c8169..6936c08d852 100644 --- a/app/assets/javascripts/repository/graphql.js +++ b/app/assets/javascripts/repository/graphql.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import axios from '~/lib/utils/axios_utils'; import createDefaultClient from '~/lib/graphql'; import introspectionQueryResultData from './fragmentTypes.json'; import { fetchLogsTree } from './log_tree'; @@ -27,6 +28,11 @@ const defaultClient = createDefaultClient( }); }); }, + readme(_, { url }) { + return axios + .get(url, { params: { viewer: 'rich', format: 'json' } }) + .then(({ data }) => ({ ...data, __typename: 'ReadmeFile' })); + }, }, }, { diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index f9727960040..d826f209815 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -3,13 +3,18 @@ import createRouter from './router'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; import LastCommit from './components/last_commit.vue'; +import TreeActionLink from './components/tree_action_link.vue'; +import DirectoryDownloadLinks from './components/directory_download_links.vue'; import apolloProvider from './graphql'; import { setTitle } from './utils/title'; import { parseBoolean } from '../lib/utils/common_utils'; +import { webIDEUrl } from '../lib/utils/url_utility'; +import { __ } from '../locale'; export default function setupVueRepositoryList() { const el = document.getElementById('js-tree-list'); - const { projectPath, projectShortPath, ref, fullName } = el.dataset; + const { dataset } = el; + const { projectPath, projectShortPath, ref, fullName } = dataset; const router = createRouter(projectPath, ref); apolloProvider.clients.defaultClient.cache.writeData({ @@ -22,19 +27,7 @@ export default function setupVueRepositoryList() { }); router.afterEach(({ params: { pathMatch } }) => { - const isRoot = pathMatch === undefined || pathMatch === '/'; - setTitle(pathMatch, ref, fullName); - - if (!isRoot) { - document - .querySelectorAll('.js-keep-hidden-on-navigation') - .forEach(elem => elem.classList.add('hidden')); - } - - document - .querySelectorAll('.js-hide-on-navigation') - .forEach(elem => elem.classList.toggle('hidden', !isRoot)); }); const breadcrumbEl = document.getElementById('js-repo-breadcrumb'); @@ -88,7 +81,68 @@ export default function setupVueRepositoryList() { }, }); - return new Vue({ + const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); + const { historyLink } = treeHistoryLinkEl.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: treeHistoryLinkEl, + router, + render(h) { + return h(TreeActionLink, { + props: { + path: historyLink + (this.$route.params.pathMatch || '/'), + text: __('History'), + }, + }); + }, + }); + + const webIdeLinkEl = document.getElementById('js-tree-web-ide-link'); + + if (webIdeLinkEl) { + // eslint-disable-next-line no-new + new Vue({ + el: webIdeLinkEl, + router, + render(h) { + return h(TreeActionLink, { + props: { + path: webIDEUrl(`/${projectPath}/edit/${ref}/-${this.$route.params.pathMatch || '/'}`), + text: __('Web IDE'), + cssClass: 'qa-web-ide-button', + }, + }); + }, + }); + } + + const directoryDownloadLinks = document.getElementById('js-directory-downloads'); + + if (directoryDownloadLinks) { + // eslint-disable-next-line no-new + new Vue({ + el: directoryDownloadLinks, + router, + render(h) { + const currentPath = this.$route.params.pathMatch || '/'; + + if (currentPath !== '/') { + return h(DirectoryDownloadLinks, { + props: { + currentPath: currentPath.replace(/^\//, ''), + links: JSON.parse(directoryDownloadLinks.dataset.links), + }, + }); + } + + return null; + }, + }); + } + + // eslint-disable-next-line no-new + new Vue({ el, router, apolloProvider, @@ -96,4 +150,6 @@ export default function setupVueRepositoryList() { return h(App); }, }); + + return { router, data: dataset }; } diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index 2c19aca2397..5bf30e625a0 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -1,4 +1,5 @@ import axios from '~/lib/utils/axios_utils'; +import { normalizeData } from 'ee_else_ce/repository/utils/commit'; import getCommits from './queries/getCommits.query.graphql'; import getProjectPath from './queries/getProjectPath.query.graphql'; import getRef from './queries/getRef.query.graphql'; @@ -6,18 +7,6 @@ import getRef from './queries/getRef.query.graphql'; let fetchpromise; let resolvers = []; -export function normalizeData(data) { - return data.map(d => ({ - sha: d.commit.id, - message: d.commit.message, - committedDate: d.commit.committed_date, - commitPath: d.commit_path, - fileName: d.file_name, - type: d.type, - __typename: 'LogTreeCommit', - })); -} - export function resolveCommit(commits, { resolve, entry }) { const commit = commits.find(c => c.fileName === entry.name && c.type === entry.type); @@ -37,9 +26,12 @@ export function fetchLogsTree(client, path, offset, resolver = null) { const { ref } = client.readQuery({ query: getRef }); fetchpromise = axios - .get(`${gon.gitlab_url}/${projectPath}/refs/${ref}/logs_tree${path ? `/${path}` : ''}`, { - params: { format: 'json', offset }, - }) + .get( + `${gon.relative_url_root}/${projectPath}/refs/${ref}/logs_tree/${path.replace(/^\//, '')}`, + { + params: { format: 'json', offset }, + }, + ) .then(({ data, headers }) => { const headerLogsOffset = headers['more-logs-offset']; const { commits } = client.readQuery({ query: getCommits }); diff --git a/app/assets/javascripts/repository/pages/index.vue b/app/assets/javascripts/repository/pages/index.vue index 2d92e9174ca..29786bf4ec8 100644 --- a/app/assets/javascripts/repository/pages/index.vue +++ b/app/assets/javascripts/repository/pages/index.vue @@ -1,18 +1,25 @@ <script> -import FileTable from '../components/table/index.vue'; +import TreePage from './tree.vue'; +import { updateElementsVisibility } from '../utils/dom'; export default { components: { - FileTable, + TreePage, }, - data() { - return { - ref: '', - }; + mounted() { + this.updateProjectElements(true); + }, + beforeDestroy() { + this.updateProjectElements(false); + }, + methods: { + updateProjectElements(isShow) { + updateElementsVisibility('.js-show-on-project-root', isShow); + }, }, }; </script> <template> - <file-table path="/" /> + <tree-page path="/" /> </template> diff --git a/app/assets/javascripts/repository/pages/tree.vue b/app/assets/javascripts/repository/pages/tree.vue index 3b898d1aa91..dd4d437f4dd 100644 --- a/app/assets/javascripts/repository/pages/tree.vue +++ b/app/assets/javascripts/repository/pages/tree.vue @@ -1,9 +1,10 @@ <script> -import FileTable from '../components/table/index.vue'; +import TreeContent from '../components/tree_content.vue'; +import { updateElementsVisibility } from '../utils/dom'; export default { components: { - FileTable, + TreeContent, }, props: { path: { @@ -12,9 +13,26 @@ export default { default: '/', }, }, + computed: { + isRoot() { + return this.path === '/'; + }, + }, + watch: { + isRoot: { + immediate: true, + handler: 'updateElements', + }, + }, + methods: { + updateElements(isRoot) { + updateElementsVisibility('.js-show-on-root', isRoot); + updateElementsVisibility('.js-hide-on-root', !isRoot); + }, + }, }; </script> <template> - <file-table :path="path" /> + <tree-content :path="path" /> </template> diff --git a/app/assets/javascripts/repository/queries/commit.fragment.graphql b/app/assets/javascripts/repository/queries/commit.fragment.graphql new file mode 100644 index 00000000000..9bb13c475c7 --- /dev/null +++ b/app/assets/javascripts/repository/queries/commit.fragment.graphql @@ -0,0 +1,8 @@ +fragment TreeEntryCommit on LogTreeCommit { + sha + message + committedDate + commitPath + fileName + type +} diff --git a/app/assets/javascripts/repository/queries/getCommit.query.graphql b/app/assets/javascripts/repository/queries/getCommit.query.graphql index e2a2d831e47..e4aeaaff8fe 100644 --- a/app/assets/javascripts/repository/queries/getCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/getCommit.query.graphql @@ -1,10 +1,7 @@ +#import "ee_else_ce/repository/queries/commit.fragment.graphql" + query getCommit($fileName: String!, $type: String!, $path: String!) { commit(path: $path, fileName: $fileName, type: $type) @client { - sha - message - committedDate - commitPath - fileName - type + ...TreeEntryCommit } } diff --git a/app/assets/javascripts/repository/queries/getCommits.query.graphql b/app/assets/javascripts/repository/queries/getCommits.query.graphql index df9e67cc440..0976b8f32d7 100644 --- a/app/assets/javascripts/repository/queries/getCommits.query.graphql +++ b/app/assets/javascripts/repository/queries/getCommits.query.graphql @@ -1,10 +1,7 @@ +#import "ee_else_ce/repository/queries/commit.fragment.graphql" + query getCommits { commits @client { - sha - message - committedDate - commitPath - fileName - type + ...TreeEntryCommit } } diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/getFiles.query.graphql index c4814f8e63a..2aaf5066b4a 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/getFiles.query.graphql @@ -2,6 +2,7 @@ fragment TreeEntry on Entry { id + sha name flatPath type diff --git a/app/assets/javascripts/repository/queries/getReadme.query.graphql b/app/assets/javascripts/repository/queries/getReadme.query.graphql new file mode 100644 index 00000000000..cf056330133 --- /dev/null +++ b/app/assets/javascripts/repository/queries/getReadme.query.graphql @@ -0,0 +1,5 @@ +query getReadme($url: String!) { + readme(url: $url) @client { + html + } +} diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql index 71c1bf12749..9be025afe39 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql @@ -5,22 +5,27 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { lastCommit { sha title - message + description webUrl authoredDate + authorName author { name avatarUrl webUrl } signatureHtml - latestPipeline { - detailedStatus { - detailsPath - icon - tooltip - text - group + pipelines(ref: $ref, first: 1) { + edges { + node { + detailedStatus { + detailsPath + icon + tooltip + text + group + } + } } } } diff --git a/app/assets/javascripts/repository/router.js b/app/assets/javascripts/repository/router.js index 9322c81ab97..ebf0a7091ea 100644 --- a/app/assets/javascripts/repository/router.js +++ b/app/assets/javascripts/repository/router.js @@ -16,7 +16,7 @@ export default function createRouter(base, baseRef) { name: 'treePath', component: TreePage, props: route => ({ - path: route.params.pathMatch && route.params.pathMatch.replace(/^\//, ''), + path: route.params.pathMatch && (route.params.pathMatch.replace(/^\//, '') || '/'), }), }, { diff --git a/app/assets/javascripts/repository/utils/commit.js b/app/assets/javascripts/repository/utils/commit.js new file mode 100644 index 00000000000..6c204b57b37 --- /dev/null +++ b/app/assets/javascripts/repository/utils/commit.js @@ -0,0 +1,13 @@ +// eslint-disable-next-line import/prefer-default-export +export function normalizeData(data, extra = () => {}) { + return data.map(d => ({ + sha: d.commit.id, + message: d.commit.message, + committedDate: d.commit.committed_date, + commitPath: d.commit_path, + fileName: d.file_name, + type: d.type, + __typename: 'LogTreeCommit', + ...extra(d), + })); +} diff --git a/app/assets/javascripts/repository/utils/dom.js b/app/assets/javascripts/repository/utils/dom.js new file mode 100644 index 00000000000..963e6fc0bc4 --- /dev/null +++ b/app/assets/javascripts/repository/utils/dom.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export const updateElementsVisibility = (selector, isVisible) => { + document.querySelectorAll(selector).forEach(elem => elem.classList.toggle('hidden', !isVisible)); +}; diff --git a/app/assets/javascripts/repository/utils/readme.js b/app/assets/javascripts/repository/utils/readme.js new file mode 100644 index 00000000000..e43b2bdc33a --- /dev/null +++ b/app/assets/javascripts/repository/utils/readme.js @@ -0,0 +1,21 @@ +const MARKDOWN_EXTENSIONS = ['mdown', 'mkd', 'mkdn', 'md', 'markdown']; +const ASCIIDOC_EXTENSIONS = ['adoc', 'ad', 'asciidoc']; +const OTHER_EXTENSIONS = ['textile', 'rdoc', 'org', 'creole', 'wiki', 'mediawiki', 'rst']; +const EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...ASCIIDOC_EXTENSIONS, ...OTHER_EXTENSIONS]; +const PLAIN_FILENAMES = ['readme', 'index']; +const FILE_REGEXP = new RegExp( + `^(${PLAIN_FILENAMES.join('|')})(.(${EXTENSIONS.join('|')}))?$`, + 'i', +); +const PLAIN_FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i'); +const EXTENSIONS_REGEXP = new RegExp(`.(${EXTENSIONS.join('|')})$`, 'i'); + +// eslint-disable-next-line import/prefer-default-export +export const readmeFile = blobs => { + const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1); + + const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1); + const plainReadme = readMeFiles.find(f => f.name.search(PLAIN_FILE_REGEXP) !== -1); + + return previewableReadme || plainReadme; +}; diff --git a/app/assets/javascripts/repository/utils/title.js b/app/assets/javascripts/repository/utils/title.js index 87d54c01200..ff16fbdd420 100644 --- a/app/assets/javascripts/repository/utils/title.js +++ b/app/assets/javascripts/repository/utils/title.js @@ -1,10 +1,14 @@ +const DEFAULT_TITLE = '· GitLab'; // eslint-disable-next-line import/prefer-default-export export const setTitle = (pathMatch, ref, project) => { - if (!pathMatch) return; + if (!pathMatch) { + document.title = `${project} ${DEFAULT_TITLE}`; + return; + } const path = pathMatch.replace(/^\//, ''); const isEmpty = path === ''; /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`; + document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project} ${DEFAULT_TITLE}`; }; diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 87454ee056f..fa5649679d7 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, no-var, consistent-return, one-var, no-else-return, no-param-reassign */ +/* eslint-disable func-names, consistent-return, no-else-return, no-param-reassign */ import $ from 'jquery'; import _ from 'underscore'; @@ -44,12 +44,11 @@ Sidebar.prototype.addEventListeners = function() { }; Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { - var $allGutterToggleIcons, $this, isExpanded, tooltipLabel; + const $this = $(this); + const isExpanded = $this.find('i').hasClass('fa-angle-double-right'); + const tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); + const $allGutterToggleIcons = $('.js-sidebar-toggle i'); e.preventDefault(); - $this = $(this); - isExpanded = $this.find('i').hasClass('fa-angle-double-right'); - tooltipLabel = isExpanded ? __('Expand sidebar') : __('Collapse sidebar'); - $allGutterToggleIcons = $('.js-sidebar-toggle i'); if (isExpanded) { $allGutterToggleIcons.removeClass('fa-angle-double-right').addClass('fa-angle-double-left'); @@ -77,15 +76,9 @@ Sidebar.prototype.sidebarToggleClicked = function(e, triggered) { }; Sidebar.prototype.toggleTodo = function(e) { - var $this, ajaxType, url; - $this = $(e.currentTarget); - ajaxType = $this.data('deletePath') ? 'delete' : 'post'; - - if ($this.data('deletePath')) { - url = String($this.data('deletePath')); - } else { - url = String($this.data('createPath')); - } + const $this = $(e.currentTarget); + const ajaxType = $this.data('deletePath') ? 'delete' : 'post'; + const url = String($this.data('deletePath') || $this.data('createPath')); $this.tooltip('hide'); @@ -141,13 +134,12 @@ Sidebar.prototype.todoUpdateDone = function(data) { }; Sidebar.prototype.sidebarDropdownLoading = function() { - var $loading, $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this) + const $sidebarCollapsedIcon = $(this) .closest('.block') .find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); - i = $sidebarCollapsedIcon.find('i'); - $loading = $('<i class="fa fa-spinner fa-spin"></i>'); + const img = $sidebarCollapsedIcon.find('img'); + const i = $sidebarCollapsedIcon.find('i'); + const $loading = $('<i class="fa fa-spinner fa-spin"></i>'); if (img.length) { img.before($loading); return img.hide(); @@ -158,13 +150,12 @@ Sidebar.prototype.sidebarDropdownLoading = function() { }; Sidebar.prototype.sidebarDropdownLoaded = function() { - var $sidebarCollapsedIcon, i, img; - $sidebarCollapsedIcon = $(this) + const $sidebarCollapsedIcon = $(this) .closest('.block') .find('.sidebar-collapsed-icon'); - img = $sidebarCollapsedIcon.find('img'); + const img = $sidebarCollapsedIcon.find('img'); $sidebarCollapsedIcon.find('i.fa-spin').remove(); - i = $sidebarCollapsedIcon.find('i'); + const i = $sidebarCollapsedIcon.find('i'); if (img.length) { return img.show(); } else { @@ -173,19 +164,17 @@ Sidebar.prototype.sidebarDropdownLoaded = function() { }; Sidebar.prototype.sidebarCollapseClicked = function(e) { - var $block, sidebar; if ($(e.currentTarget).hasClass('dont-change-state')) { return; } - sidebar = e.data; + const sidebar = e.data; e.preventDefault(); - $block = $(this).closest('.block'); + const $block = $(this).closest('.block'); return sidebar.openDropdown($block); }; Sidebar.prototype.openDropdown = function(blockOrName) { - var $block; - $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; + const $block = _.isString(blockOrName) ? this.getBlock(blockOrName) : blockOrName; if (!this.isOpen()) { this.setCollapseAfterUpdate($block); this.toggleSidebar('open'); @@ -204,10 +193,9 @@ Sidebar.prototype.setCollapseAfterUpdate = function($block) { }; Sidebar.prototype.onSidebarDropdownHidden = function(e) { - var $block, sidebar; - sidebar = e.data; + const sidebar = e.data; e.preventDefault(); - $block = $(e.target).closest('.block'); + const $block = $(e.target).closest('.block'); return sidebar.sidebarDropdownHidden($block); }; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index f6722ff7bca..8d888a574d8 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -1,4 +1,4 @@ -/* eslint-disable no-return-assign, one-var, no-var, consistent-return, class-methods-use-this, no-lonely-if, vars-on-top */ +/* eslint-disable no-return-assign, consistent-return, class-methods-use-this */ import $ from 'jquery'; import { escape, throttle } from 'underscore'; @@ -29,14 +29,14 @@ const KEYCODE = { }; function setSearchOptions() { - var $projectOptionsDataEl = $('.js-search-project-options'); - var $groupOptionsDataEl = $('.js-search-group-options'); - var $dashboardOptionsDataEl = $('.js-search-dashboard-options'); + const $projectOptionsDataEl = $('.js-search-project-options'); + const $groupOptionsDataEl = $('.js-search-group-options'); + const $dashboardOptionsDataEl = $('.js-search-dashboard-options'); if ($projectOptionsDataEl.length) { gl.projectOptions = gl.projectOptions || {}; - var projectPath = $projectOptionsDataEl.data('projectPath'); + const projectPath = $projectOptionsDataEl.data('projectPath'); gl.projectOptions[projectPath] = { name: $projectOptionsDataEl.data('name'), @@ -49,7 +49,7 @@ function setSearchOptions() { if ($groupOptionsDataEl.length) { gl.groupOptions = gl.groupOptions || {}; - var groupPath = $groupOptionsDataEl.data('groupPath'); + const groupPath = $groupOptionsDataEl.data('groupPath'); gl.groupOptions[groupPath] = { name: $groupOptionsDataEl.data('name'), @@ -95,10 +95,9 @@ export class SearchAutocomplete { this.createAutocomplete(); } - this.searchInput.addClass('disabled'); - this.saveTextLength(); this.bindEvents(); this.dropdownToggle.dropdown(); + this.searchInput.addClass('js-autocomplete-disabled'); } // Finds an element inside wrapper element @@ -107,7 +106,7 @@ export class SearchAutocomplete { this.onClearInputClick = this.onClearInputClick.bind(this); this.onSearchInputFocus = this.onSearchInputFocus.bind(this); this.onSearchInputKeyUp = this.onSearchInputKeyUp.bind(this); - this.onSearchInputKeyDown = this.onSearchInputKeyDown.bind(this); + this.onSearchInputChange = this.onSearchInputChange.bind(this); this.setScrollFade = this.setScrollFade.bind(this); } getElement(selector) { @@ -118,10 +117,6 @@ export class SearchAutocomplete { return (this.originalState = this.serializeState()); } - saveTextLength() { - return (this.lastTextLength = this.searchInput.val().length); - } - createAutocomplete() { return this.searchInput.glDropdown({ filterInputBlur: false, @@ -318,12 +313,16 @@ export class SearchAutocomplete { } bindEvents() { - this.searchInput.on('keydown', this.onSearchInputKeyDown); + this.searchInput.on('input', this.onSearchInputChange); this.searchInput.on('keyup', this.onSearchInputKeyUp); this.searchInput.on('focus', this.onSearchInputFocus); this.searchInput.on('blur', this.onSearchInputBlur); this.clearInput.on('click', this.onClearInputClick); this.dropdownContent.on('scroll', throttle(this.setScrollFade, 250)); + + this.searchInput.on('click', e => { + e.stopPropagation(); + }); } enableAutocomplete() { @@ -338,47 +337,23 @@ export class SearchAutocomplete { if (!this.dropdown.hasClass('show')) { this.loadingSuggestions = false; this.dropdownToggle.dropdown('toggle'); - return this.searchInput.removeClass('disabled'); + return this.searchInput.removeClass('js-autocomplete-disabled'); } } - // Saves last length of the entered text - onSearchInputKeyDown() { - return this.saveTextLength(); + onSearchInputChange() { + this.enableAutocomplete(); } onSearchInputKeyUp(e) { switch (e.keyCode) { - case KEYCODE.BACKSPACE: - // When removing the last character and no badge is present - if (this.lastTextLength === 1) { - this.disableAutocomplete(); - } - // When removing any character from existin value - if (this.lastTextLength > 1) { - this.enableAutocomplete(); - } - break; case KEYCODE.ESCAPE: this.restoreOriginalState(); break; case KEYCODE.ENTER: this.disableAutocomplete(); break; - case KEYCODE.UP: - case KEYCODE.DOWN: - return; default: - // Handle the case when deleting the input value other than backspace - // e.g. Pressing ctrl + backspace or ctrl + x - if (this.searchInput.val() === '') { - this.disableAutocomplete(); - } else { - // We should display the menu only when input is not empty - if (e.keyCode !== KEYCODE.ENTER) { - this.enableAutocomplete(); - } - } } this.wrap.toggleClass('has-value', Boolean(e.target.value)); } @@ -412,36 +387,33 @@ export class SearchAutocomplete { } restoreOriginalState() { - var i, input, inputs, len; - inputs = Object.keys(this.originalState); - for (i = 0, len = inputs.length; i < len; i += 1) { - input = inputs[i]; + const inputs = Object.keys(this.originalState); + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; this.getElement(`#${input}`).val(this.originalState[input]); } } resetSearchState() { - var i, input, inputs, len, results; - inputs = Object.keys(this.originalState); - results = []; - for (i = 0, len = inputs.length; i < len; i += 1) { - input = inputs[i]; + const inputs = Object.keys(this.originalState); + const results = []; + for (let i = 0, len = inputs.length; i < len; i += 1) { + const input = inputs[i]; results.push(this.getElement(`#${input}`).val('')); } return results; } disableAutocomplete() { - if (!this.searchInput.hasClass('disabled') && this.dropdown.hasClass('show')) { - this.searchInput.addClass('disabled'); - this.dropdown.removeClass('show').trigger('hidden.bs.dropdown'); + if (!this.searchInput.hasClass('js-autocomplete-disabled') && this.dropdown.hasClass('show')) { + this.searchInput.addClass('js-autocomplete-disabled'); + this.dropdown.dropdown('toggle'); this.restoreMenu(); } } restoreMenu() { - var html; - html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; + const html = `<ul><li class="dropdown-menu-empty-item"><a>${__('Loading...')}</a></li></ul>`; return this.dropdownContent.html(html); } diff --git a/app/assets/javascripts/raven/index.js b/app/assets/javascripts/sentry/index.js index 4dd0175e528..06e4e0aa507 100644 --- a/app/assets/javascripts/raven/index.js +++ b/app/assets/javascripts/sentry/index.js @@ -1,8 +1,8 @@ -import RavenConfig from './raven_config'; +import SentryConfig from './sentry_config'; const index = function index() { - RavenConfig.init({ - sentryDsn: gon.sentry_dsn, + SentryConfig.init({ + dsn: gon.sentry_dsn, currentUserId: gon.current_user_id, whitelistUrls: process.env.NODE_ENV === 'production' @@ -15,7 +15,7 @@ const index = function index() { }, }); - return RavenConfig; + return SentryConfig; }; index(); diff --git a/app/assets/javascripts/raven/raven_config.js b/app/assets/javascripts/sentry/sentry_config.js index 7259e0df104..bc3b2f16a6a 100644 --- a/app/assets/javascripts/raven/raven_config.js +++ b/app/assets/javascripts/sentry/sentry_config.js @@ -1,4 +1,4 @@ -import Raven from 'raven-js'; +import * as Sentry from '@sentry/browser'; import $ from 'jquery'; import { __ } from '~/locale'; @@ -26,7 +26,7 @@ const IGNORE_ERRORS = [ 'conduitPage', ]; -const IGNORE_URLS = [ +const BLACKLIST_URLS = [ // Facebook flakiness /graph\.facebook\.com/i, // Facebook blocked @@ -43,62 +43,62 @@ const IGNORE_URLS = [ /metrics\.itunes\.apple\.com\.edgesuite\.net\//i, ]; -const SAMPLE_RATE = 95; +const SAMPLE_RATE = 0.95; -const RavenConfig = { +const SentryConfig = { IGNORE_ERRORS, - IGNORE_URLS, + BLACKLIST_URLS, SAMPLE_RATE, init(options = {}) { this.options = options; this.configure(); - this.bindRavenErrors(); + this.bindSentryErrors(); if (this.options.currentUserId) this.setUser(); }, configure() { - Raven.config(this.options.sentryDsn, { - release: this.options.release, - tags: this.options.tags, - whitelistUrls: this.options.whitelistUrls, - environment: this.options.environment, - ignoreErrors: this.IGNORE_ERRORS, - ignoreUrls: this.IGNORE_URLS, - shouldSendCallback: this.shouldSendSample.bind(this), - }).install(); + const { dsn, release, tags, whitelistUrls, environment } = this.options; + Sentry.init({ + dsn, + release, + tags, + whitelistUrls, + environment, + ignoreErrors: this.IGNORE_ERRORS, // TODO: Remove in favor of https://gitlab.com/gitlab-org/gitlab/issues/35144 + blacklistUrls: this.BLACKLIST_URLS, + sampleRate: SAMPLE_RATE, + }); }, setUser() { - Raven.setUserContext({ + Sentry.setUser({ id: this.options.currentUserId, }); }, - bindRavenErrors() { - $(document).on('ajaxError.raven', this.handleRavenErrors); + bindSentryErrors() { + $(document).on('ajaxError.sentry', this.handleSentryErrors); }, - handleRavenErrors(event, req, config, err) { + handleSentryErrors(event, req, config, err) { const error = err || req.statusText; - const responseText = req.responseText || __('Unknown response text'); + const { responseText = __('Unknown response text') } = req; + const { type, url, data } = config; + const { status } = req; - Raven.captureMessage(error, { + Sentry.captureMessage(error, { extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, + type, + url, + data, + status, response: responseText, error, event, }, }); }, - - shouldSendSample() { - return Math.random() * 100 <= this.SAMPLE_RATE; - }, }; -export default RavenConfig; +export default SentryConfig; diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue index 95a2c8cce6e..91fe5fc50a9 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -33,6 +33,8 @@ export default { <div class="block subscriptions"> <subscriptions :loading="store.isFetching.subscriptions" + :project-emails-disabled="store.projectEmailsDisabled" + :subscribe-disabled-description="store.subscribeDisabledDescription" :subscribed="store.subscribed" @toggleSubscription="onToggleSubscription" /> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index ea5edb3ce3f..0e489b28593 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -26,6 +26,16 @@ export default { required: false, default: false, }, + projectEmailsDisabled: { + type: Boolean, + required: false, + default: false, + }, + subscribeDisabledDescription: { + type: String, + required: false, + default: '', + }, subscribed: { type: Boolean, required: false, @@ -42,11 +52,23 @@ export default { return this.subscribed === null; }, notificationIcon() { + if (this.projectEmailsDisabled) { + return ICON_OFF; + } return this.subscribed ? ICON_ON : ICON_OFF; }, notificationTooltip() { + if (this.projectEmailsDisabled) { + return this.subscribeDisabledDescription; + } return this.subscribed ? LABEL_ON : LABEL_OFF; }, + notificationText() { + if (this.projectEmailsDisabled) { + return this.subscribeDisabledDescription; + } + return __('Notifications'); + }, }, methods: { /** @@ -81,6 +103,7 @@ export default { <template> <div> <span + ref="tooltip" v-tooltip class="sidebar-collapsed-icon" :title="notificationTooltip" @@ -96,8 +119,9 @@ export default { class="sidebar-item-icon is-active" /> </span> - <span class="issuable-header-text hide-collapsed float-left"> {{ __('Notifications') }} </span> + <span class="issuable-header-text hide-collapsed float-left"> {{ notificationText }} </span> <toggle-button + v-if="!projectEmailsDisabled" ref="toggleButton" :is-loading="showLoadingState" :value="subscribed" diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js index 63c4a2a3f84..66f7f9e3c66 100644 --- a/app/assets/javascripts/sidebar/stores/sidebar_store.js +++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js @@ -28,6 +28,8 @@ export default class SidebarStore { this.moveToProjectId = 0; this.isLockDialogOpen = false; this.participants = []; + this.projectEmailsDisabled = false; + this.subscribeDisabledDescription = ''; this.subscribed = null; SidebarStore.singleton = this; @@ -53,6 +55,8 @@ export default class SidebarStore { } setSubscriptionsData(data) { + this.projectEmailsDisabled = data.project_emails_disabled || false; + this.subscribeDisabledDescription = data.subscribe_disabled_description; this.isFetching.subscriptions = false; this.subscribed = data.subscribed || false; } diff --git a/app/assets/javascripts/sourcegraph/index.js b/app/assets/javascripts/sourcegraph/index.js new file mode 100644 index 00000000000..796e90bf08e --- /dev/null +++ b/app/assets/javascripts/sourcegraph/index.js @@ -0,0 +1,28 @@ +function loadScript(path) { + const script = document.createElement('script'); + script.type = 'application/javascript'; + script.src = path; + script.defer = true; + document.head.appendChild(script); +} + +/** + * Loads the Sourcegraph integration for support for Sourcegraph extensions and + * code intelligence. + */ +export default function initSourcegraph() { + const { url } = gon.sourcegraph || {}; + + if (!url) { + return; + } + + const assetsUrl = new URL('/assets/webpack/sourcegraph/', window.location.href); + const scriptPath = new URL('scripts/integration.bundle.js', assetsUrl).href; + + window.SOURCEGRAPH_ASSETS_URL = assetsUrl.href; + window.SOURCEGRAPH_URL = url; + window.SOURCEGRAPH_INTEGRATION = 'gitlab-integration'; + + loadScript(scriptPath); +} diff --git a/app/assets/javascripts/sourcegraph/load.js b/app/assets/javascripts/sourcegraph/load.js new file mode 100644 index 00000000000..f9491505d42 --- /dev/null +++ b/app/assets/javascripts/sourcegraph/load.js @@ -0,0 +1,6 @@ +import initSourcegraph from './index'; + +/** + * Load sourcegraph in it's own listener so that it's isolated from failures. + */ +document.addEventListener('DOMContentLoaded', initSourcegraph); diff --git a/app/assets/javascripts/tree.js b/app/assets/javascripts/tree.js index 69b3d20914a..a530c4a99e2 100644 --- a/app/assets/javascripts/tree.js +++ b/app/assets/javascripts/tree.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, no-var, one-var, no-else-return, class-methods-use-this */ +/* eslint-disable func-names, consistent-return, one-var, no-else-return, class-methods-use-this */ import $ from 'jquery'; import { visitUrl } from './lib/utils/url_utility'; @@ -9,9 +9,8 @@ export default class TreeView { // Code browser tree slider // Make the entire tree-item row clickable, but not if clicking another link (like a commit message) $('.tree-content-holder .tree-item').on('click', function(e) { - var $clickedEl, path; - $clickedEl = $(e.target); - path = $('.tree-item-file-name a', this).attr('href'); + const $clickedEl = $(e.target); + const path = $('.tree-item-file-name a', this).attr('href'); if (!$clickedEl.is('a') && !$clickedEl.is('.str-truncated')) { if (e.metaKey || e.which === 2) { e.preventDefault(); @@ -26,11 +25,10 @@ export default class TreeView { } initKeyNav() { - var li, liSelected; - li = $('tr.tree-item'); - liSelected = null; + const li = $('tr.tree-item'); + let liSelected = null; return $('body').keydown(e => { - var next, path; + let next, path; if ($('input:focus').length > 0 && (e.which === 38 || e.which === 40)) { return false; } diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index c0b7587be10..7d6a725b30f 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -73,9 +73,14 @@ const handleUserPopoverMouseOver = event => { location: userData.location, bio: userData.bio, organization: userData.organization, + status: userData.status, loaded: true, }); + if (userData.status) { + return Promise.resolve(); + } + return UsersCache.retrieveStatusById(userId); }) .then(status => { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index 339e154affc..57be97855e3 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -65,9 +65,13 @@ export default { simplePoll(this.checkRebaseStatus); }) .catch(error => { - this.rebasingError = error.merge_error; this.isMakingRequest = false; - Flash(__('Something went wrong. Please try again.')); + + if (error.response && error.response.data && error.response.data.merge_error) { + this.rebasingError = error.response.data.merge_error; + } else { + Flash(__('Something went wrong. Please try again.')); + } }); }, checkRebaseStatus(continuePolling, stopPolling) { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue index 1e6f4c376c1..66155ddcdd9 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/content_viewer.vue @@ -18,6 +18,11 @@ export default { required: false, default: 0, }, + filePath: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: false, @@ -52,6 +57,7 @@ export default { <component :is="viewer" :path="path" + :file-path="filePath" :file-size="fileSize" :project-path="projectPath" :content="content" diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 655f0054887..c50304f057d 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -16,6 +16,11 @@ export default { type: String, required: true, }, + filePath: { + type: String, + required: false, + default: '', + }, projectPath: { type: String, required: true, @@ -48,6 +53,7 @@ export default { this.isLoading = true; const postBody = { text: this.content, + path: this.filePath, }; const postOptions = { cancelToken: axiosSource.token, diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue index ebb253ff422..b874bedab36 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/diff_viewer.vue @@ -23,6 +23,11 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, oldPath: { type: String, required: true, @@ -31,6 +36,11 @@ export default { type: String, required: true, }, + oldSize: { + type: Number, + required: false, + default: 0, + }, projectPath: { type: String, required: false, @@ -85,6 +95,8 @@ export default { :diff-mode="diffMode" :new-path="fullNewPath" :old-path="fullOldPath" + :old-size="oldSize" + :new-size="newSize" :project-path="projectPath" :a-mode="aMode" :b-mode="bMode" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue index a17fc022195..4dbfdb6d79c 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff/two_up_viewer.vue @@ -14,6 +14,16 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, + oldSize: { + type: Number, + required: false, + default: 0, + }, }, }; </script> @@ -22,12 +32,14 @@ export default { <div class="two-up view d-flex"> <image-viewer :path="oldPath" + :file-size="oldSize" :render-info="true" inner-css-classes="frame deleted" class="wrap w-50" /> <image-viewer :path="newPath" + :file-size="newSize" :render-info="true" :inner-css-classes="['frame', 'added']" class="wrap w-50" diff --git a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue index cab92297ca7..e30871b66fc 100644 --- a/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue @@ -22,6 +22,16 @@ export default { type: String, required: true, }, + newSize: { + type: Number, + required: false, + default: 0, + }, + oldSize: { + type: Number, + required: false, + default: 0, + }, }, data() { return { diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 341c9534763..611001df32f 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -218,7 +218,7 @@ export default { display: inline-block; flex: 1; max-width: inherit; - height: 18px; + height: 19px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 73f4dfef062..80908cbbc9c 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -61,7 +61,7 @@ export default { </script> <template> - <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true"> + <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true" v-on="$listeners"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> </template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index 715cf97f0ac..1524b313f9f 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -1,7 +1,6 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; - import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { @@ -16,44 +15,47 @@ export default { type: Array, required: true, }, + iconSize: { + type: Number, + required: false, + default: 24, + }, + imgCssClasses: { + type: String, + required: false, + default: '', + }, + maxVisible: { + type: Number, + required: false, + default: 3, + }, }, data() { return { - maxVisibleAssignees: 2, - maxAssigneeAvatars: 3, maxAssignees: 99, }; }, computed: { - countOverLimit() { - return this.assignees.length - this.maxVisibleAssignees; - }, assigneesToShow() { - if (this.assignees.length > this.maxAssigneeAvatars) { - return this.assignees.slice(0, this.maxVisibleAssignees); - } - return this.assignees; + const numShownAssignees = this.assignees.length - this.numHiddenAssignees; + return this.assignees.slice(0, numShownAssignees); }, assigneesCounterTooltip() { - const { countOverLimit, maxAssignees } = this; - const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit; - - return sprintf(__('%{count} more assignees'), { count }); + return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees }); }, - shouldRenderAssigneesCounter() { - const assigneesCount = this.assignees.length; - if (assigneesCount <= this.maxAssigneeAvatars) { - return false; + numHiddenAssignees() { + if (this.assignees.length > this.maxVisible) { + return this.assignees.length - this.maxVisible + 1; } - - return assigneesCount > this.countOverLimit; + return 0; }, assigneeCounterLabel() { - if (this.countOverLimit > this.maxAssignees) { + if (this.numHiddenAssignees > this.maxAssignees) { return `${this.maxAssignees}+`; } - return `+${this.countOverLimit}`; + return `+${this.numHiddenAssignees}`; }, }, methods: { @@ -81,8 +83,9 @@ export default { :key="assignee.id" :link-href="webUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" + :img-css-classes="imgCssClasses" :img-src="avatarUrl(assignee)" - :img-size="24" + :img-size="iconSize" class="js-no-trigger" tooltip-placement="bottom" > @@ -92,7 +95,7 @@ export default { </span> </user-avatar-link> <span - v-if="shouldRenderAssigneesCounter" + v-if="numHiddenAssignees > 0" v-gl-tooltip :title="assigneesCounterTooltip" class="avatar-counter" diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 4d27d1c9179..af4ac024e4f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -124,7 +124,7 @@ export default { :cursor-offset="4" :tag-content="lineContent" icon="doc-code" - class="qa-suggestion-btn js-suggestion-btn" + class="js-suggestion-btn" @click="handleSuggestDismissed" /> <gl-popover @@ -168,7 +168,7 @@ export default { :prepend="true" tag="* [ ] " :button-title="__('Add a task list')" - icon="task-done" + icon="list-task" /> <toolbar-button :tag="mdTable" diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue index 12de3671477..cc700440a23 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue @@ -55,7 +55,7 @@ export default { <gl-button v-else-if="canApply" v-gl-tooltip.viewport="__('This also resolves the discussion')" - class="btn-inverted qa-apply-btn js-apply-btn" + class="btn-inverted js-apply-btn" :disabled="isApplying" variant="success" @click="applySuggestion" diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index d6dfe9eded8..f8e010c4f42 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -17,9 +17,11 @@ * /> */ import $ from 'jquery'; -import { mapGetters } from 'vuex'; +import { mapGetters, mapActions } from 'vuex'; +import { GlSkeletonLoading } from '@gitlab/ui'; import noteHeader from '~/notes/components/note_header.vue'; import Icon from '~/vue_shared/components/icon.vue'; +import descriptionVersionHistoryMixin from 'ee_else_ce/notes/mixins/description_version_history'; import TimelineEntryItem from './timeline_entry_item.vue'; import { spriteIcon } from '../../../lib/utils/common_utils'; import initMRPopovers from '~/mr_popover/'; @@ -32,7 +34,9 @@ export default { Icon, noteHeader, TimelineEntryItem, + GlSkeletonLoading, }, + mixins: [descriptionVersionHistoryMixin], props: { note: { type: Object, @@ -75,13 +79,16 @@ export default { mounted() { initMRPopovers(this.$el.querySelectorAll('.gfm-merge_request')); }, + methods: { + ...mapActions(['fetchDescriptionVersion']), + }, }; </script> <template> <timeline-entry-item :id="noteAnchorId" - :class="{ target: isTargetNote }" + :class="{ target: isTargetNote, 'pr-0': shouldShowDescriptionVersion }" class="note system-note note-wrapper" > <div class="timeline-icon" v-html="iconHtml"></div> @@ -89,14 +96,18 @@ export default { <div class="note-header"> <note-header :author="note.author" :created-at="note.created_at" :note-id="note.id"> <span v-html="actionTextHtml"></span> + <template v-if="canSeeDescriptionVersion" slot="extra-controls"> + · + <button type="button" class="btn-blank btn-link" @click="toggleDescriptionVersion"> + {{ __('Compare with previous version') }} + <icon :name="descriptionVersionToggleIcon" :size="12" class="append-left-5" /> + </button> + </template> </note-header> </div> <div class="note-body"> <div - :class="{ - 'system-note-commit-list': hasMoreCommits, - 'hide-shade': expanded, - }" + :class="{ 'system-note-commit-list': hasMoreCommits, 'hide-shade': expanded }" class="note-text md" v-html="note.note_html" ></div> @@ -106,6 +117,12 @@ export default { <span>{{ __('Toggle commit list') }}</span> </div> </div> + <div v-if="shouldShowDescriptionVersion" class="description-version pt-2"> + <pre v-if="isLoadingDescriptionVersion" class="loading-state"> + <gl-skeleton-loading /> + </pre> + <pre v-else class="wrapper mt-2" v-html="descriptionVersion"></pre> + </div> </div> </div> </timeline-entry-item> diff --git a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue index 478e44d104c..f984a0a6203 100644 --- a/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue +++ b/app/assets/javascripts/vue_shared/components/project_selector/project_selector.vue @@ -1,6 +1,6 @@ <script> import _ from 'underscore'; -import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlInfiniteScroll } from '@gitlab/ui'; import ProjectListItem from './project_list_item.vue'; const SEARCH_INPUT_TIMEOUT_MS = 500; @@ -10,6 +10,7 @@ export default { components: { GlLoadingIcon, GlSearchBoxByType, + GlInfiniteScroll, ProjectListItem, }, props: { @@ -41,6 +42,11 @@ export default { required: false, default: false, }, + totalResults: { + type: Number, + required: false, + default: 0, + }, }, data() { return { @@ -51,6 +57,9 @@ export default { projectClicked(project) { this.$emit('projectClicked', project); }, + bottomReached() { + this.$emit('bottomReached'); + }, isSelected(project) { return Boolean(_.find(this.selectedProjects, { id: project.id })); }, @@ -71,18 +80,25 @@ export default { @input="onInput" /> <div class="d-flex flex-column"> - <gl-loading-icon v-if="showLoadingIndicator" :size="2" class="py-2 px-4" /> - <div v-if="!showLoadingIndicator" class="d-flex flex-column"> - <project-list-item - v-for="project in projectSearchResults" - :key="project.id" - :selected="isSelected(project)" - :project="project" - :matcher="searchQuery" - class="js-project-list-item" - @click="projectClicked(project)" - /> - </div> + <gl-loading-icon v-if="showLoadingIndicator" :size="1" class="py-2 px-4" /> + <gl-infinite-scroll + :max-list-height="402" + :fetched-items="projectSearchResults.length" + :total-items="totalResults" + @bottomReached="bottomReached" + > + <div v-if="!showLoadingIndicator" slot="items" class="d-flex flex-column"> + <project-list-item + v-for="project in projectSearchResults" + :key="project.id" + :selected="isSelected(project)" + :project="project" + :matcher="searchQuery" + class="js-project-list-item" + @click="projectClicked(project)" + /> + </div> + </gl-infinite-scroll> <div v-if="showNoResultsMessage" class="text-muted ml-2 js-no-results-message"> {{ __('Sorry, no projects matched your search') }} </div> diff --git a/app/assets/javascripts/vue_shared/components/slot_switch.vue b/app/assets/javascripts/vue_shared/components/slot_switch.vue new file mode 100644 index 00000000000..67726f01744 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/slot_switch.vue @@ -0,0 +1,35 @@ +<script> +/** + * Allows to toggle slots based on an array of slot names. + */ +export default { + name: 'SlotSwitch', + + props: { + activeSlotNames: { + type: Array, + required: true, + }, + + tagName: { + type: String, + required: false, + default: 'div', + }, + }, + + computed: { + allSlotNames() { + return Object.keys(this.$slots); + }, + }, +}; +</script> + +<template> + <component :is="tagName"> + <template v-for="slotName in allSlotNames"> + <slot v-if="activeSlotNames.includes(slotName)" :name="slotName"></slot> + </template> + </component> +</template> diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue new file mode 100644 index 00000000000..f7dc00a345c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -0,0 +1,76 @@ +<script> +import _ from 'underscore'; + +import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui'; + +const isValidItem = item => + _.isString(item.eventName) && _.isString(item.title) && _.isString(item.description); + +export default { + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownItem, + }, + + props: { + actionItems: { + type: Array, + required: true, + validator(value) { + return value.length > 1 && value.every(isValidItem); + }, + }, + menuClass: { + type: String, + required: false, + default: '', + }, + }, + + data() { + return { + selectedItem: this.actionItems[0], + }; + }, + + computed: { + dropdownToggleText() { + return this.selectedItem.title; + }, + }, + + methods: { + triggerEvent() { + this.$emit(this.selectedItem.eventName); + }, + }, +}; +</script> + +<template> + <gl-dropdown + :menu-class="`dropdown-menu-selectable ${menuClass}`" + split + :text="dropdownToggleText" + v-bind="$attrs" + @click="triggerEvent" + > + <template v-for="(item, itemIndex) in actionItems"> + <gl-dropdown-item + :key="item.eventName" + :active="selectedItem === item" + active-class="is-active" + @click="selectedItem = item" + > + <strong>{{ item.title }}</strong> + <div>{{ item.description }}</div> + </gl-dropdown-item> + + <gl-dropdown-divider + v-if="itemIndex < actionItems.length - 1" + :key="`${item.eventName}-divider`" + /> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index 8bcad7ac765..43935cf31d5 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -32,7 +32,7 @@ export default { </script> <template> <time - v-gl-tooltip="{ placement: tooltipPlacement }" + v-gl-tooltip.viewport="{ placement: tooltipPlacement }" :class="cssClass" :title="tooltipTitle(time)" v-text="timeFormated(time)" 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 7c7d46ee759..4a72cca5f02 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 @@ -51,7 +51,7 @@ export default { </script> <template> - <gl-popover :target="target" boundary="viewport" placement="top" show> + <gl-popover :target="target" boundary="viewport" placement="top" offset="0, 1" show> <div class="user-popover d-flex"> <div class="p-1 flex-shrink-1"> <user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" /> @@ -90,7 +90,7 @@ export default { name="location" class="category-icon flex-shrink-0" /> - <span class="ml-1">{{ user.location }}</span> + <span v-if="user.location" class="ml-1">{{ user.location }}</span> <gl-skeleton-loading v-if="locationIsLoading" :lines="1" diff --git a/app/assets/javascripts/zen_mode.js b/app/assets/javascripts/zen_mode.js index 7a60ab1380f..044d703630e 100644 --- a/app/assets/javascripts/zen_mode.js +++ b/app/assets/javascripts/zen_mode.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, consistent-return, camelcase, class-methods-use-this */ +/* eslint-disable consistent-return, camelcase, class-methods-use-this */ // Zen Mode (full screen) textarea // @@ -47,26 +47,16 @@ export default class ZenMode { e.preventDefault(); return $(e.currentTarget).trigger('zen_mode:leave'); }); - $(document).on( - 'zen_mode:enter', - (function(_this) { - return function(e) { - return _this.enter( - $(e.target) - .closest('.md-area') - .find('.zen-backdrop'), - ); - }; - })(this), - ); - $(document).on( - 'zen_mode:leave', - (function(_this) { - return function() { - return _this.exit(); - }; - })(this), - ); + $(document).on('zen_mode:enter', e => { + this.enter( + $(e.target) + .closest('.md-area') + .find('.zen-backdrop'), + ); + }); + $(document).on('zen_mode:leave', () => { + this.exit(); + }); $(document).on('keydown', e => { // Esc if (e.keyCode === 27) { diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss index c9b00e5ff27..885e9ac6667 100644 --- a/app/assets/stylesheets/bootstrap_migration.scss +++ b/app/assets/stylesheets/bootstrap_migration.scss @@ -282,8 +282,7 @@ pre code { white-space: pre-wrap; } -.alert, -.flash-notice { +.alert { border-radius: 0; } @@ -310,12 +309,10 @@ pre code { .alert-success, .alert-info, .alert-warning, -.alert-danger, -.flash-notice { +.alert-danger { color: $white-light; h4, - a:not(.btn), .alert-link { color: $white-light; } diff --git a/app/assets/stylesheets/framework.scss b/app/assets/stylesheets/framework.scss index 56a88ca44db..249e9a24b17 100644 --- a/app/assets/stylesheets/framework.scss +++ b/app/assets/stylesheets/framework.scss @@ -2,7 +2,7 @@ @import 'framework/variables_overrides'; @import 'framework/mixins'; -@import '@gitlab/ui/scss/gitlab_ui'; +@import '@gitlab/ui/src/scss/gitlab_ui'; @import 'bootstrap_migration'; @import 'framework/layout'; diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss index 28d7492b99c..cae7b9b5e46 100644 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -99,3 +99,13 @@ color: $gl-text-color-disabled; } } + +.group-variable-list { + color: $gray-700; + + .table-section:not(:first-child) { + @include media-breakpoint-down(sm) { + border-top: hidden; + } + } +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 4b89a2f2b04..31ea59df4c5 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -562,4 +562,20 @@ img.emoji { } .gl-font-size-small { font-size: $gl-font-size-small; } +.gl-font-size-large { font-size: $gl-font-size-large; } + .gl-line-height-24 { line-height: $gl-line-height-24; } + +.gl-font-size-12 { font-size: $gl-font-size-12; } +.gl-font-size-14 { font-size: $gl-font-size-14; } +.gl-font-size-16 { font-size: $gl-font-size-16; } +.gl-font-size-20 { font-size: $gl-font-size-20; } +.gl-font-size-28 { font-size: $gl-font-size-28; } +.gl-font-size-42 { font-size: $gl-font-size-42; } + +.border-section { + @include gl-py-6; + @include gl-m-0; + + border-top: 1px solid $border-color; +} diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ce74aa6ed02..d53a4c1286c 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -506,7 +506,8 @@ .dropdown-menu-selectable { li { a, - button { + button, + .dropdown-item { padding: 8px 40px; position: relative; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 487fbf0fcff..4938215b2e7 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -376,6 +376,10 @@ span.idiff { float: none; } + .file-actions .ide-edit-button { + z-index: 2; + } + @include media-breakpoint-down(xs) { display: block; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 5984efd1cf8..2d826064569 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -249,7 +249,7 @@ } .filtered-search-input-dropdown-menu { - max-height: $dropdown-max-height; + max-height: $dropdown-max-height-lg; max-width: 280px; overflow: auto; @@ -357,12 +357,18 @@ } } - .filter-dropdown-container > div { - margin: 0; + .filter-dropdown-container { + > div { + margin: 0; - > .btn { - margin: 0 0 10px; - width: 100%; + > .btn { + margin: 0 0 10px; + width: 100%; + } + } + + .board-labels-toggle-wrapper { + margin-bottom: $gl-input-padding; } } diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 8fc2fd5f53b..d604d97d270 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -12,17 +12,22 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); position: -webkit-sticky; top: $flash-container-top; z-index: 251; + } - .flash-content { - box-shadow: 0 2px 4px 0 $notification-box-shadow-color; - } + &.flash-container-page { + margin-bottom: 0; + } + + &:empty { + margin: 0; } .close-icon-wrapper { - padding: ($gl-btn-padding + $gl-padding-4) $gl-padding $gl-btn-padding; + padding: ($gl-padding + $gl-padding-4) $gl-padding $gl-padding; position: absolute; right: 0; top: 0; + bottom: 0; cursor: pointer; .close-icon { @@ -31,13 +36,12 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); } } - .flash-notice, .flash-alert, + .flash-notice, .flash-success, .flash-warning { - border-radius: $border-radius-default; - color: $white-light; - padding-right: $gl-padding * 2; + padding: $gl-padding $gl-padding-32 $gl-padding ($gl-padding + $gl-padding-4); + margin-top: 10px; .container-fluid, .container-fluid.container-limited { @@ -45,75 +49,31 @@ $notification-box-shadow-color: rgba(0, 0, 0, 0.25); } } - .flash-notice { - @extend .alert; - background-color: $blue-500; - margin: 0; - - &.flash-notice-persistent { - background-color: $blue-100; - color: $gl-text-color; + .flash-alert { + background-color: $red-100; + color: $red-700; + } - a { - color: $blue-600; + .flash-notice { + background-color: $blue-100; + color: $blue-700; + } - &:hover { - color: $blue-800; - text-decoration: none; - } - } - } + .flash-success { + background-color: $theme-green-100; + color: $green-700; } .flash-warning { - @extend .alert; background-color: $orange-100; - color: $orange-900; + color: $orange-800; cursor: default; - margin: 0; } .flash-text, .flash-action { display: inline-block; } - - .flash-alert { - @extend .alert; - background-color: $red-500; - margin: 0; - - .flash-action { - margin-left: 5px; - text-decoration: none; - font-weight: $gl-font-weight-normal; - border-bottom: 1px solid; - - &:hover { - border-color: transparent; - } - } - } - - .flash-success { - @extend .alert; - background-color: $green-500; - margin: 0; - } - - &.flash-container-page { - margin-bottom: 0; - - .flash-notice, - .flash-alert, - .flash-success { - border-radius: 0; - } - } - - &:empty { - margin: 0; - } } @include media-breakpoint-down(sm) { diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 7205324e86f..8038a367fb9 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -33,7 +33,8 @@ body { &.limit-container-width { .flash-container.sticky { max-width: $limited-layout-width; - margin: 0 auto; + margin-right: auto; + margin-left: auto; } } } diff --git a/app/assets/stylesheets/framework/memory_graph.scss b/app/assets/stylesheets/framework/memory_graph.scss index 81cdf6b59e4..c84010c6f10 100644 --- a/app/assets/stylesheets/framework/memory_graph.scss +++ b/app/assets/stylesheets/framework/memory_graph.scss @@ -1,11 +1,7 @@ .memory-graph-container { svg { background: $white-light; - cursor: pointer; - - &:hover { - box-shadow: 0 0 4px $gray-darkest inset; - } + border: 1px solid $gray-200; } path { diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 9c924559135..757264add93 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -49,7 +49,7 @@ line-height: $line-height-base; position: relative; min-height: $modal-body-height; - padding: #{2 * $grid-size} #{6 * $grid-size} #{2 * $grid-size} #{2 * $grid-size}; + padding: #{2 * $grid-size}; text-align: left; white-space: normal; @@ -70,9 +70,9 @@ margin: 0; } - .btn + .btn:not(.dropdown-toggle-split), .btn + .btn-group, - .btn-group + .btn { + .btn-group + .btn, + .btn-group + .btn-group { margin-left: $grid-size; } @@ -83,13 +83,6 @@ @include media-breakpoint-down(xs) { flex-direction: column; - .btn + .btn:not(.dropdown-toggle-split), - .btn + .btn-group, - .btn-group + .btn { - margin-left: 0; - margin-top: $grid-size; - } - .btn-group .btn + .btn { margin-left: -1px; margin-top: 0; diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index fd6f80e26cb..1878fac1c60 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -20,6 +20,17 @@ @extend .gl-responsive-table-row-layout; margin-top: 10px; border: 1px solid $border-color; + color: $gray-700; + + &.gl-responsive-table-row-clickable { + &:hover { + background-color: $gray-light; + + .underline { + text-decoration: underline; + } + } + } @include media-breakpoint-up(md) { margin: 0; diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index f57b1d9f351..404f60f17ee 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -4,7 +4,12 @@ } .snippet-filename { - padding: 0 2px; + color: $gl-text-color-secondary; + font-weight: normal; + } + + .snippet-info { + color: $gl-text-color-secondary; } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index dfc39d8e03b..0f77c451fac 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -21,6 +21,27 @@ $spacing-scale: ( ); /* + * Why another sizing scale??? + * Great question, friend! + * This size scale is a "backport" of the equivalent set of "named" sizes + * (e.g. `xl` versus `70`) that came from the following design document as of 2019-10-23: + * + * https://gitlab-org.gitlab.io/gitlab-design/hosted/design-gitlab-specs/forms-spec-previews/ + * + * (See `input-` items at the bottom) + * + * The presumption here is that these sizes will be standardized in GitLab UI and thus will be + * broadly useful here in the GitLab product when not using the GitLab UI components. + */ +$size-scale: ( + 'xs': #{10 * $grid-size}, + 's': #{20 * $grid-size}, + 'm': #{30 * $grid-size}, + 'l': #{40 * $grid-size}, + 'xl': #{70 * $grid-size} +); + +/* * Color schema */ $darken-normal-factor: 7%; @@ -304,6 +325,12 @@ $gl-grayish-blue: #7f8fa4; $gl-gray-dark: #313236; $gl-gray-light: #5c5c5c; $gl-header-color: #4c4e54; +$gl-font-size-12: 12px; +$gl-font-size-14: 14px; +$gl-font-size-16: 16px; +$gl-font-size-20: 20px; +$gl-font-size-28: 28px; +$gl-font-size-42: 42px; $type-scale: ( 1: 12px, diff --git a/app/assets/stylesheets/framework/vue_transitions.scss b/app/assets/stylesheets/framework/vue_transitions.scss index e3bdc0b0199..a082cd25abe 100644 --- a/app/assets/stylesheets/framework/vue_transitions.scss +++ b/app/assets/stylesheets/framework/vue_transitions.scss @@ -11,3 +11,27 @@ .fade-leave-to { opacity: 0; } + +.slide-enter-from-element { + &.slide-enter, + &.slide-leave-to { + transform: translateX(-150%); + } +} + +.slide-enter-to-element { + &.slide-enter, + &.slide-leave-to { + transform: translateX(150%); + } +} + +.slide-enter-active, +.slide-leave-active { + transition: transform 300ms ease-out; +} + +.slide-enter-to, +.slide-leave { + transform: translateX(0); +} diff --git a/app/assets/stylesheets/mailer.scss b/app/assets/stylesheets/mailer.scss new file mode 100644 index 00000000000..f7d93870a25 --- /dev/null +++ b/app/assets/stylesheets/mailer.scss @@ -0,0 +1,117 @@ +@import 'framework/variables'; + +// Do not use 3-letter hex codes, bgcolor vs css background-color is problematic in emails +// See https://stackoverflow.com/questions/28551981/why-are-3-digit-hex-color-code-values-interpreted-differently-in-internet-explor +// +// stylelint-disable color-hex-length + +$mailer-font: 'Helvetica Neue', Helvetica, Arial, sans-serif; +$mailer-text-color: #333333; +$mailer-bg-color: #fafafa; +$mailer-link-color: #3777b0; +$mailer-link-muted-color: #333333; +$mailer-line-cell-bg-color: #6b4fbb; +$mailer-wrapper-cell-bg-color: #ffffff; +$mailer-wrapper-cell-border-color: #ededed; +$mailer-header-footer-text-color: #5c5c5c; + +body { + margin: 0 !important; + background-color: $mailer-bg-color; + padding: 0; + text-align: center; + min-width: 640px; + width: 100%; + height: 100%; + font-family: $mailer-font; +} + +table#body { + background-color: $mailer-bg-color; + margin: 0; + padding: 0; + text-align: center; + min-width: 640px; + width: 100%; +} + +a { + color: $mailer-link-color; + text-decoration: none; + + &.muted { + color: $mailer-link-muted-color; + } +} + +.highlight { + font-weight: 500; +} + +tr td { + font-family: $mailer-font; +} + +tr.line td { + font-family: $mailer-font; + background-color: $mailer-line-cell-bg-color; + height: 4px; + font-size: 4px; + line-height: 4px; +} + +tr.header td, +tr.footer td, +td.footer-message { + font-family: $mailer-font; + padding: 25px 0; + font-size: 13px; + line-height: 1.6; + color: $mailer-header-footer-text-color; +} + +table.wrapper { + width: 640px; + margin: 0 auto; + border-collapse: separate; + border-spacing: 0; + + td.wrapper-cell { + font-family: $mailer-font; + background-color: $mailer-wrapper-cell-bg-color; + text-align: left; + padding: 18px 25px; + border: 1px solid $mailer-wrapper-cell-border-color; + border-radius: 3px; + overflow: hidden; + } +} + +table.content { + width: 100%; + border-collapse: separate; + border-spacing: 0; + + td.text-content { + font-family: $mailer-font; + color: $mailer-text-color; + font-size: 15px; + font-weight: 400; + line-height: 1.4; + padding: 15px 5px; + text-align: center; + } +} + +tr.footer td { + img { + display: block; + margin: 0 auto 1em; + } + + .mng-notif-link, + .help-link { + color: $mailer-link-color; + text-decoration: none; + } +} diff --git a/app/assets/stylesheets/mailer_client_specific.scss b/app/assets/stylesheets/mailer_client_specific.scss new file mode 100644 index 00000000000..41bedecf90f --- /dev/null +++ b/app/assets/stylesheets/mailer_client_specific.scss @@ -0,0 +1,65 @@ +/* CLIENT-SPECIFIC STYLES */ + +// These are client-specific rules, ignore some linting rules +// +// stylelint-disable property-no-vendor-prefix, property-no-unknown, length-zero-no-unit +// scss-lint:disable PropertySpelling, ZeroUnit + +body, +table, +td, +a { + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +table, +td { + mso-table-lspace: 0pt; + mso-table-rspace: 0pt; +} + +img { + -ms-interpolation-mode: bicubic; +} + +.hidden { + display: none !important; + visibility: hidden !important; +} + +/* iOS BLUE LINKS */ +a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + font-size: inherit !important; + font-family: inherit !important; + font-weight: inherit !important; + line-height: inherit !important; +} + +/* ANDROID MARGIN HACK */ +div[style*='margin: 16px 0'] { + margin: 0 !important; +} + +@media only screen and (max-width: 639px) { + body, + #body { + min-width: 320px !important; + } + + table.wrapper { + width: 100% !important; + min-width: 320px !important; + } + + table.wrapper td.wrapper-cell { + border-left: 0 !important; + border-right: 0 !important; + border-radius: 0 !important; + padding-left: 10px !important; + padding-right: 10px !important; + } +} + diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 2a7a53d8bd7..d26979bc174 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -497,7 +497,7 @@ .add-issues-footer-to-list { padding-left: $gl-vert-padding; padding-right: $gl-vert-padding; - line-height: 34px; + line-height: $input-height; } .issue-card-selected { @@ -545,3 +545,11 @@ .board-issue-path.js-show-tooltip { cursor: help; } + +.board-labels-toggle-wrapper { + /** + * Make the wrapper the same height as a button so it aligns properly when the + * filtered-search-box input element increases in size on Linux smaller breakpoints + */ + height: $input-height; +} diff --git a/app/assets/stylesheets/pages/error_details.scss b/app/assets/stylesheets/pages/error_details.scss new file mode 100644 index 00000000000..0515db914e9 --- /dev/null +++ b/app/assets/stylesheets/pages/error_details.scss @@ -0,0 +1,18 @@ +.error-details { + li { + @include gl-line-height-32; + } +} + +.stacktrace { + .file-title { + svg { + vertical-align: middle; + top: -1px; + } + } + + .line_content.old::before { + content: none !important; + } +} diff --git a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss index 8b1ec1ced35..5a80ea79600 100644 --- a/app/assets/stylesheets/pages/experimental_separate_sign_up.scss +++ b/app/assets/stylesheets/pages/experimental_separate_sign_up.scss @@ -23,6 +23,7 @@ .signup-heading h2 { font-weight: $gl-font-weight-bold; + padding: 0 $gl-padding; @include media-breakpoint-down(md) { font-size: $gl-font-size-large; diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index 3febf4cf826..a8de8303a19 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -17,21 +17,6 @@ } } -.graphs { - .graph-author-email { - float: right; - color: $gl-gray-500; - } - - .graph-additions { - color: $green-600; - } - - .graph-deletions { - color: $red-500; - } -} - .svg-graph-container { width: 100%; diff --git a/app/assets/stylesheets/pages/milestone.scss b/app/assets/stylesheets/pages/milestone.scss index 00d84df1650..b399662997c 100644 --- a/app/assets/stylesheets/pages/milestone.scss +++ b/app/assets/stylesheets/pages/milestone.scss @@ -30,7 +30,8 @@ $status-box-line-height: 26px; margin-bottom: $gl-padding-4; } - .milestone-progress { + .milestone-progress, + .milestone-release-links { a { color: $blue-600; } @@ -238,10 +239,6 @@ $status-box-line-height: 26px; } } -.milestone-range { - color: $gl-text-color-tertiary; -} - @include media-breakpoint-down(xs) { .milestone-banner-text, .milestone-banner-link { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 21a9f143039..1da9f691639 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -310,6 +310,17 @@ $note-form-margin-left: 72px; .note-body { overflow: hidden; + .description-version { + pre { + max-height: $dropdown-max-height-lg; + white-space: pre-wrap; + + &.loading-state { + height: 94px; + } + } + } + .system-note-commit-list-toggler { color: $blue-600; padding: 10px 0 0; diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 1b2af932733..364fe3da71e 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -871,7 +871,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; position: relative; - top: 1px; + top: auto; vertical-align: initial; } } @@ -1082,3 +1082,13 @@ button.mini-pipeline-graph-dropdown-toggle { .legend-success { color: $green-500; } + +.test-reports-table { + .build-trace { + @include build-trace(); + } +} + +.progress-bar.bg-primary { + background-color: $blue-500 !important; +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b2c1d0b6dc5..d96cc163738 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -252,6 +252,7 @@ .fa-caret-down { margin-left: 3px; + line-height: 0; &.dropdown-btn-icon { margin-left: 0; @@ -273,6 +274,12 @@ height: 24px; } + .git-clone-holder { + .btn { + height: auto; + } + } + .dropdown-toggle, .clone-dropdown-btn { .fa { diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss index 0fbf7033aa5..390ebd48685 100644 --- a/app/assets/stylesheets/pages/reports.scss +++ b/app/assets/stylesheets/pages/reports.scss @@ -131,7 +131,6 @@ .modal-security-report-dast { .modal-dialog { - width: $modal-lg; max-width: $modal-lg; } diff --git a/app/assets/stylesheets/pages/stat_graph.scss b/app/assets/stylesheets/pages/stat_graph.scss deleted file mode 100644 index 31ccdacbc02..00000000000 --- a/app/assets/stylesheets/pages/stat_graph.scss +++ /dev/null @@ -1,62 +0,0 @@ -.tint-box { - background: $stat-graph-common-bg; - position: relative; - margin-bottom: 10px; -} - -.area { - fill: $green-500; - fill-opacity: 0.5; -} - -.axis { - font-size: 10px; -} - -#contributors-master { - @include media-breakpoint-up(md) { - @include make-col-ready(); - @include make-col(12); - } -} - -#contributors { - flex: 1; - - .contributors-list { - margin: 0 0 10px; - list-style: none; - padding: 0; - } - - .person { - @include media-breakpoint-up(md) { - @include make-col-ready(); - @include make-col(6); - } - - margin-top: 10px; - - @include media-breakpoint-down(xs) { - width: 100%; - } - - .spark { - display: block; - background: $stat-graph-common-bg; - width: 100%; - } - - .area-contributor { - fill: $orange-500; - } - } -} - -.selection rect { - fill-opacity: 0.1; - stroke-width: 1px; - stroke-opacity: 0.4; - shape-rendering: crispedges; - stroke-dasharray: 3 3; -} diff --git a/app/assets/stylesheets/performance_bar.scss b/app/assets/stylesheets/performance_bar.scss index ad7d87f0bf6..87e650c7659 100644 --- a/app/assets/stylesheets/performance_bar.scss +++ b/app/assets/stylesheets/performance_bar.scss @@ -18,6 +18,11 @@ width: 200px; } + input { + color: $gl-gray-400; + width: $input-short-width - 60px; + } + &.disabled { display: none; } @@ -25,7 +30,8 @@ &.production { background-color: $perf-bar-production; - select { + select, + input { background: $perf-bar-production; } } @@ -33,7 +39,8 @@ &.staging { background-color: $perf-bar-staging; - select { + select, + input { background: $perf-bar-staging; } } @@ -41,7 +48,8 @@ &.development { background-color: $perf-bar-development; - select { + select, + input { background: $perf-bar-development; } } diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index d2906ce0780..3b3a2778b23 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -16,8 +16,18 @@ } } +@each $index, $size in $size-scale { + #{'.mw-#{$index}'} { + max-width: $size; + } +} + .border-width-1px { border-width: 1px; } .border-style-dashed { border-style: dashed; } .border-style-solid { border-style: solid; } .border-color-blue-300 { border-color: $blue-300; } .border-color-default { border-color: $border-color; } +.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } + +.gl-w-64 { width: px-to-rem($grid-size * 8); } +.gl-h-64 { height: px-to-rem($grid-size * 8); } diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index d5537023b26..31d825c235b 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true class Admin::AbuseReportsController < Admin::ApplicationController - # rubocop: disable CodeReuse/ActiveRecord def index - @abuse_reports = AbuseReport.order(id: :desc).page(params[:page]) - @abuse_reports.includes(:reporter, :user) + @abuse_reports = AbuseReportsFinder.new(params).execute end - # rubocop: enable CodeReuse/ActiveRecord def destroy abuse_report = AbuseReport.find(params[:id]) diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 22e629ccf59..907b295870d 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -44,7 +44,7 @@ class Admin::ApplicationsController < Admin::ApplicationController def destroy @application.destroy - redirect_to admin_applications_url, status: 302, notice: _('Application was successfully destroyed.') + redirect_to admin_applications_url, status: :found, notice: _('Application was successfully destroyed.') end private diff --git a/app/controllers/admin/groups_controller.rb b/app/controllers/admin/groups_controller.rb index 85a37fcd43e..5455cefdc8e 100644 --- a/app/controllers/admin/groups_controller.rb +++ b/app/controllers/admin/groups_controller.rb @@ -69,7 +69,7 @@ class Admin::GroupsController < Admin::ApplicationController Groups::DestroyService.new(@group, current_user).async_execute redirect_to admin_groups_path, - status: 302, + status: :found, alert: _('Group %{group_name} was scheduled for deletion.') % { group_name: @group.name } end diff --git a/app/controllers/admin/identities_controller.rb b/app/controllers/admin/identities_controller.rb index f518f7a657f..8f2e34a6294 100644 --- a/app/controllers/admin/identities_controller.rb +++ b/app/controllers/admin/identities_controller.rb @@ -38,9 +38,9 @@ class Admin::IdentitiesController < Admin::ApplicationController def destroy if @identity.destroy RepairLdapBlockedUserService.new(@user).execute - redirect_to admin_user_identities_path(@user), status: 302, notice: _('User identity was successfully removed.') + redirect_to admin_user_identities_path(@user), status: :found, notice: _('User identity was successfully removed.') else - redirect_to admin_user_identities_path(@user), status: 302, alert: _('Failed to remove user identity.') + redirect_to admin_user_identities_path(@user), status: :found, alert: _('Failed to remove user identity.') end end diff --git a/app/controllers/admin/keys_controller.rb b/app/controllers/admin/keys_controller.rb index 340eecd7632..58ea19d1210 100644 --- a/app/controllers/admin/keys_controller.rb +++ b/app/controllers/admin/keys_controller.rb @@ -17,9 +17,9 @@ class Admin::KeysController < Admin::ApplicationController respond_to do |format| if key.destroy - format.html { redirect_to keys_admin_user_path(user), status: 302, notice: _('User key was successfully removed.') } + format.html { redirect_to keys_admin_user_path(user), status: :found, notice: _('User key was successfully removed.') } else - format.html { redirect_to keys_admin_user_path(user), status: 302, alert: _('Failed to remove user key.') } + format.html { redirect_to keys_admin_user_path(user), status: :found, alert: _('Failed to remove user key.') } end end end diff --git a/app/controllers/admin/labels_controller.rb b/app/controllers/admin/labels_controller.rb index 90c1694fd2e..6cb206c1686 100644 --- a/app/controllers/admin/labels_controller.rb +++ b/app/controllers/admin/labels_controller.rb @@ -43,7 +43,7 @@ class Admin::LabelsController < Admin::ApplicationController respond_to do |format| format.html do - redirect_to admin_labels_path, status: 302, notice: _('Label was removed') + redirect_to admin_labels_path, status: :found, notice: _('Label was removed') end format.js end diff --git a/app/controllers/admin/projects_controller.rb b/app/controllers/admin/projects_controller.rb index 0e8c69eb7d6..cdedc34e634 100644 --- a/app/controllers/admin/projects_controller.rb +++ b/app/controllers/admin/projects_controller.rb @@ -41,7 +41,7 @@ class Admin::ProjectsController < Admin::ApplicationController redirect_to admin_projects_path, status: :found rescue Projects::DestroyService::DestroyError => ex - redirect_to admin_projects_path, status: 302, alert: ex.message + redirect_to admin_projects_path, status: :found, alert: ex.message end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index 45cf0d3207e..a41d8a22650 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -13,7 +13,7 @@ class Admin::SpamLogsController < Admin::ApplicationController if params[:remove_user] spam_log.remove_user(deleted_by: current_user) redirect_to admin_spam_logs_path, - status: 302, + status: :found, notice: _('User %{username} was successfully removed.') % { username: spam_log.user.username } else spam_log.destroy diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 4c1ac8f206a..9fbfc59f630 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -169,7 +169,7 @@ class Admin::UsersController < Admin::ApplicationController user.delete_async(deleted_by: current_user, params: params.permit(:hard_delete)) respond_to do |format| - format.html { redirect_to admin_users_path, status: 302, notice: _("The user is being deleted.") } + format.html { redirect_to admin_users_path, status: :found, notice: _("The user is being deleted.") } format.json { head :ok } end end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 27e88ae569e..25c1d80b117 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base include EnforcesTwoFactorAuthentication include WithPerformanceBar include SessionlessAuthentication + include SessionsHelper include ConfirmEmailWarning include Gitlab::Tracking::ControllerConcern include Gitlab::Experimentation::ControllerConcern @@ -29,13 +30,13 @@ class ApplicationController < ActionController::Base before_action :active_user_check, unless: :devise_controller? before_action :set_usage_stats_consent_flag before_action :check_impersonation_availability - before_action :require_role + before_action :required_signup_info around_action :set_locale around_action :set_session_storage after_action :set_page_title_header, if: :json_request? - after_action :limit_unauthenticated_session_times + after_action :limit_session_time, if: -> { !current_user } protect_from_forgery with: :exception, prepend: true @@ -57,7 +58,7 @@ class ApplicationController < ActionController::Base rescue_from Encoding::CompatibilityError do |exception| log_exception(exception) - render "errors/encoding", layout: "errors", status: 500 + render "errors/encoding", layout: "errors", status: :internal_server_error end rescue_from ActiveRecord::RecordNotFound do |exception| @@ -103,24 +104,6 @@ class ApplicationController < ActionController::Base end end - # By default, all sessions are given the same expiration time configured in - # the session store (e.g. 1 week). However, unauthenticated users can - # generate a lot of sessions, primarily for CSRF verification. It makes - # sense to reduce the TTL for unauthenticated to something much lower than - # the default (e.g. 1 hour) to limit Redis memory. In addition, Rails - # creates a new session after login, so the short TTL doesn't even need to - # be extended. - def limit_unauthenticated_session_times - return if current_user - - # Rack sets this header, but not all tests may have it: https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L251-L259 - return unless request.env['rack.session.options'] - - # This works because Rack uses these options every time a request is handled: - # https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342 - request.env['rack.session.options'][:expire_after] = Settings.gitlab['unauthenticated_session_expire_delay'] - end - def render(*args) super.tap do # Set a header for custom error pages to prevent them from being intercepted by gitlab-workhorse @@ -214,25 +197,29 @@ class ApplicationController < ActionController::Base end def git_not_found! - render "errors/git_not_found.html", layout: "errors", status: 404 + render "errors/git_not_found.html", layout: "errors", status: :not_found end def render_403 respond_to do |format| format.any { head :forbidden } - format.html { render "errors/access_denied", layout: "errors", status: 403 } + format.html { render "errors/access_denied", layout: "errors", status: :forbidden } end end def render_404 respond_to do |format| - format.html { render "errors/not_found", layout: "errors", status: 404 } + format.html { render "errors/not_found", layout: "errors", status: :not_found } # Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file format.js { render json: '', status: :not_found, content_type: 'application/json' } format.any { head :not_found } end end + def respond_201 + head :created + end + def respond_422 head :unprocessable_entity end @@ -551,10 +538,13 @@ class ApplicationController < ActionController::Base @current_user_mode ||= Gitlab::Auth::CurrentUserMode.new(current_user) end - # A user requires a role when they are part of the experimental signup flow (executed by the Growth team). Users - # are redirected to the welcome page when their role is required and the experiment is enabled for the current user. - def require_role - return unless current_user && current_user.role_required? && experiment_enabled?(:signup_flow) + # A user requires a role and have the setup_for_company attribute set when they are part of the experimental signup + # flow (executed by the Growth team). Users are redirected to the welcome page when their role is required and the + # experiment is enabled for the current user. + def required_signup_info + return unless current_user + return unless current_user.role_required? + return unless experiment_enabled?(:signup_flow) store_location_for :user, request.fullpath diff --git a/app/controllers/boards/issues_controller.rb b/app/controllers/boards/issues_controller.rb index 9894dd7d180..1298b33471b 100644 --- a/app/controllers/boards/issues_controller.rb +++ b/app/controllers/boards/issues_controller.rb @@ -13,7 +13,7 @@ module Boards requires_cross_project_access if: -> { board&.group_board? } - before_action :whitelist_query_limiting, only: [:index, :update, :bulk_move] + before_action :whitelist_query_limiting, only: [:bulk_move] before_action :authorize_read_issue, only: [:index] before_action :authorize_create_issue, only: [:create] before_action :authorize_update_issue, only: [:update] @@ -130,8 +130,7 @@ module Boards end def whitelist_query_limiting - # Also see https://gitlab.com/gitlab-org/gitlab-foss/issues/42439 - Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42428') + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/35174') end def validate_id_list diff --git a/app/controllers/clusters/applications_controller.rb b/app/controllers/clusters/applications_controller.rb index 16c2365f85d..be68d0d0a1d 100644 --- a/app/controllers/clusters/applications_controller.rb +++ b/app/controllers/clusters/applications_controller.rb @@ -47,7 +47,7 @@ class Clusters::ApplicationsController < Clusters::BaseController end def cluster_application_params - params.permit(:application, :hostname, :email) + params.permit(:application, :hostname, :kibana_hostname, :email, :stack) end def cluster_application_destroy_params diff --git a/app/controllers/clusters/clusters_controller.rb b/app/controllers/clusters/clusters_controller.rb index 993aba661f3..9a539cf7c24 100644 --- a/app/controllers/clusters/clusters_controller.rb +++ b/app/controllers/clusters/clusters_controller.rb @@ -3,18 +3,22 @@ class Clusters::ClustersController < Clusters::BaseController include RoutableActions - before_action :cluster, except: [:index, :new, :create_gcp, :create_user] + before_action :cluster, only: [:cluster_status, :show, :update, :destroy] before_action :generate_gcp_authorize_url, only: [:new] before_action :validate_gcp_token, only: [:new] before_action :gcp_cluster, only: [:new] before_action :user_cluster, only: [:new] - before_action :authorize_create_cluster!, only: [:new] + before_action :authorize_create_cluster!, only: [:new, :authorize_aws_role, :revoke_aws_role, :aws_proxy] before_action :authorize_update_cluster!, only: [:update] before_action :authorize_admin_cluster!, only: [:destroy] before_action :update_applications_status, only: [:cluster_status] before_action only: [:new, :create_gcp] do push_frontend_feature_flag(:create_eks_clusters) end + before_action only: [:show] do + push_frontend_feature_flag(:enable_cluster_application_elastic_stack) + push_frontend_feature_flag(:enable_cluster_application_crossplane) + end helper_method :token_in_session @@ -40,10 +44,13 @@ class Clusters::ClustersController < Clusters::BaseController def new return unless Feature.enabled?(:create_eks_clusters) - @gke_selected = params[:provider] == 'gke' - @eks_selected = params[:provider] == 'eks' + if params[:provider] == 'aws' + @aws_role = current_user.aws_role || Aws::Role.new + @aws_role.ensure_role_external_id! - return redirect_to @authorize_url if @gke_selected && @authorize_url && !@valid_gcp_token + elsif params[:provider] == 'gcp' + redirect_to @authorize_url if @authorize_url && !@valid_gcp_token + end end # Overridding ActionController::Metal#status is NOT a good idea @@ -86,13 +93,12 @@ class Clusters::ClustersController < Clusters::BaseController end def destroy - if cluster.destroy - flash[:notice] = _('Kubernetes cluster integration was successfully removed.') - redirect_to clusterable.index_path, status: :found - else - flash[:notice] = _('Kubernetes cluster integration was not removed.') - render :show - end + response = Clusters::DestroyService + .new(current_user, destroy_params) + .execute(cluster) + + flash[:notice] = response[:message] + redirect_to clusterable.index_path, status: :found end def create_gcp @@ -112,6 +118,19 @@ class Clusters::ClustersController < Clusters::BaseController end end + def create_aws + @aws_cluster = ::Clusters::CreateService + .new(current_user, create_aws_cluster_params) + .execute + .present(current_user: current_user) + + if @aws_cluster.persisted? + head :created, location: @aws_cluster.show_path + else + render status: :unprocessable_entity, json: @aws_cluster.errors + end + end + def create_user @user_cluster = ::Clusters::CreateService .new(current_user, create_user_cluster_params) @@ -129,8 +148,37 @@ class Clusters::ClustersController < Clusters::BaseController end end + def authorize_aws_role + role = current_user.build_aws_role(create_role_params) + + role.save ? respond_201 : respond_422 + end + + def revoke_aws_role + current_user.aws_role&.destroy + + head :no_content + end + + def aws_proxy + response = Clusters::Aws::ProxyService.new( + current_user.aws_role, + params: params + ).execute + + render json: response.body, status: response.status + end + private + def destroy_params + # To be uncomented on https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # This MR got split into other since it was too big. + # + # params.permit(:cleanup) + {} + end + def update_params if cluster.provided_by_user? params.require(:cluster).permit( @@ -139,6 +187,7 @@ class Clusters::ClustersController < Clusters::BaseController :environment_scope, :managed, :base_domain, + :management_project_id, platform_kubernetes_attributes: [ :api_url, :token, @@ -152,6 +201,7 @@ class Clusters::ClustersController < Clusters::BaseController :environment_scope, :managed, :base_domain, + :management_project_id, platform_kubernetes_attributes: [ :namespace ] @@ -179,6 +229,28 @@ class Clusters::ClustersController < Clusters::BaseController ) end + def create_aws_cluster_params + params.require(:cluster).permit( + :enabled, + :name, + :environment_scope, + :managed, + provider_aws_attributes: [ + :key_name, + :role_arn, + :region, + :vpc_id, + :instance_type, + :num_nodes, + :security_group_id, + subnet_ids: [] + ]).merge( + provider_type: :aws, + platform_type: :kubernetes, + clusterable: clusterable.subject + ) + end + def create_user_cluster_params params.require(:cluster).permit( :enabled, @@ -198,6 +270,10 @@ class Clusters::ClustersController < Clusters::BaseController ) end + def create_role_params + params.require(:cluster).permit(:role_arn, :role_external_id) + end + def generate_gcp_authorize_url params = Feature.enabled?(:create_eks_clusters) ? { provider: :gke } : {} state = generate_session_key_redirect(clusterable.new_path(params).to_s) diff --git a/app/controllers/concerns/confirm_email_warning.rb b/app/controllers/concerns/confirm_email_warning.rb index 5a4b5897a4f..86df0010665 100644 --- a/app/controllers/concerns/confirm_email_warning.rb +++ b/app/controllers/concerns/confirm_email_warning.rb @@ -16,7 +16,7 @@ module ConfirmEmailWarning email = current_user.unconfirmed_email || current_user.email - flash.now[:warning] = _("Please check your email (%{email}) to verify that you own this address. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}.").html_safe % { + flash.now[:warning] = _("Please check your email (%{email}) to verify that you own this address and unlock the power of CI/CD. Didn't receive it? %{resend_link}. Wrong email address? %{update_link}.").html_safe % { email: email, resend_link: view_context.link_to(_('Resend it'), user_confirmation_path(user: { email: email }), method: :post), update_link: view_context.link_to(_('Update it'), profile_path) diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c9a8de0b290..5aa00af8910 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -148,7 +148,7 @@ module IssuableCollections when 'Issue' common_attributes + [:project, project: :namespace] when 'MergeRequest' - common_attributes + [:target_project, source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits] + common_attributes + [:target_project, :latest_merge_request_diff, source_project: :route, head_pipeline: :project, target_project: :namespace] end end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/lfs_request.rb b/app/controllers/concerns/lfs_request.rb index 417bb169f39..61072eec535 100644 --- a/app/controllers/concerns/lfs_request.rb +++ b/app/controllers/concerns/lfs_request.rb @@ -56,7 +56,7 @@ module LfsRequest documentation_url: help_url }, content_type: CONTENT_TYPE, - status: 403 + status: :forbidden ) end @@ -67,7 +67,7 @@ module LfsRequest documentation_url: help_url }, content_type: CONTENT_TYPE, - status: 404 + status: :not_found ) end diff --git a/app/controllers/concerns/metrics_dashboard.rb b/app/controllers/concerns/metrics_dashboard.rb index 62efdacb710..dc392147cb8 100644 --- a/app/controllers/concerns/metrics_dashboard.rb +++ b/app/controllers/concerns/metrics_dashboard.rb @@ -3,21 +3,26 @@ # Provides an action which fetches a metrics dashboard according # to the parameters specified by the controller. module MetricsDashboard + include RenderServiceResults + include ChecksCollaboration + extend ActiveSupport::Concern def metrics_dashboard result = dashboard_finder.find( project_for_dashboard, current_user, - metrics_dashboard_params + metrics_dashboard_params.to_h.symbolize_keys ) - if include_all_dashboards? - result[:all_dashboards] = dashboard_finder.find_all_paths(project_for_dashboard) + if include_all_dashboards? && result + result[:all_dashboards] = all_dashboards end respond_to do |format| - if result[:status] == :success + if result.nil? + format.json { continue_polling_response } + elsif result[:status] == :success format.json { render dashboard_success_response(result) } else format.json { render dashboard_error_response(result) } @@ -27,6 +32,30 @@ module MetricsDashboard private + def all_dashboards + dashboards = dashboard_finder.find_all_paths(project_for_dashboard) + dashboards.map do |dashboard| + amend_dashboard(dashboard) + end + end + + def amend_dashboard(dashboard) + project_dashboard = project_for_dashboard && !dashboard[:system_dashboard] + + dashboard[:can_edit] = project_dashboard ? can_edit?(dashboard) : false + dashboard[:project_blob_path] = project_dashboard ? dashboard_project_blob_path(dashboard) : nil + + dashboard + end + + def dashboard_project_blob_path(dashboard) + project_blob_path(project_for_dashboard, File.join(project_for_dashboard.default_branch, dashboard.fetch(:path, ""))) + end + + def can_edit?(dashboard) + can_collaborate_with_project?(project_for_dashboard, ref: project_for_dashboard.default_branch) + end + # Override in class to provide arguments to the finder. def metrics_dashboard_params {} @@ -56,7 +85,7 @@ module MetricsDashboard def dashboard_error_response(result) { - status: result[:http_status], + status: result[:http_status] || :bad_request, json: result.slice(:all_dashboards, :message, :status) } end diff --git a/app/controllers/concerns/milestone_actions.rb b/app/controllers/concerns/milestone_actions.rb index 672d31ec779..dbc575a1487 100644 --- a/app/controllers/concerns/milestone_actions.rb +++ b/app/controllers/concerns/milestone_actions.rb @@ -53,12 +53,10 @@ module MilestoneActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def milestone_redirect_path - if @project - project_milestone_path(@project, @milestone) - elsif @group - group_milestone_path(@group, @milestone.safe_title, title: @milestone.title) + if @milestone.global_milestone? + url_for(action: :show, title: @milestone.title) else - dashboard_milestone_path(@milestone.safe_title, title: @milestone.title) + url_for(action: :show) end end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index 2a9729b6ffd..c7c9f2e9b70 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -5,19 +5,10 @@ module PreviewMarkdown # rubocop:disable Gitlab/ModuleWithInstanceVariables def preview_markdown - result = PreviewMarkdownService.new(@project, current_user, params).execute - - markdown_params = - case controller_name - when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } - when 'snippets' then { skip_project_check: true } - when 'groups' then { group: group } - when 'projects' then projects_filter_params - else {} - end + result = PreviewMarkdownService.new(@project, current_user, markdown_service_params).execute render json: { - body: view_context.markdown(result[:text], markdown_params), + body: view_context.markdown(result[:text], markdown_context_params), references: { users: result[:users], suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]), @@ -26,11 +17,28 @@ module PreviewMarkdown } end + private + def projects_filter_params { issuable_state_filter_enabled: true, suggestions_filter_enabled: params[:preview_suggestions].present? } end + + def markdown_service_params + params + end + + def markdown_context_params + case controller_name + when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] } + when 'snippets' then { skip_project_check: true } + when 'groups' then { group: group } + when 'projects' then projects_filter_params + else {} + end.merge(requested_path: params[:path]) + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/redirects_for_missing_path_on_tree.rb b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb new file mode 100644 index 00000000000..085afbf3975 --- /dev/null +++ b/app/controllers/concerns/redirects_for_missing_path_on_tree.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RedirectsForMissingPathOnTree + def redirect_to_tree_root_for_missing_path(project, ref, path) + redirect_to project_tree_path(project, ref), notice: missing_path_on_ref(path, ref) + end + + private + + def missing_path_on_ref(path, ref) + _('"%{path}" did not exist on "%{ref}"') % { path: truncate_path(path), ref: ref } + end + + def truncate_path(path) + path.reverse.truncate(60, separator: "/").reverse + end +end diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb index ed9b898a2a3..826fae834fa 100644 --- a/app/controllers/concerns/renders_commits.rb +++ b/app/controllers/concerns/renders_commits.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true module RendersCommits - def limited_commits(commits) - if commits.size > MergeRequestDiff::COMMITS_SAFE_SIZE + def limited_commits(commits, commits_count) + if commits_count > MergeRequestDiff::COMMITS_SAFE_SIZE [ commits.first(MergeRequestDiff::COMMITS_SAFE_SIZE), - commits.size - MergeRequestDiff::COMMITS_SAFE_SIZE + commits_count - MergeRequestDiff::COMMITS_SAFE_SIZE ] else [commits, 0] @@ -14,9 +14,10 @@ module RendersCommits # This is used as a helper method in a controller. # rubocop: disable Gitlab/ModuleWithInstanceVariables - def set_commits_for_rendering(commits) - @total_commit_count = commits.size - limited, @hidden_commit_count = limited_commits(commits) + def set_commits_for_rendering(commits, commits_count: nil) + @total_commit_count = commits_count || commits.size + limited, @hidden_commit_count = limited_commits(commits, @total_commit_count) + commits.each(&:lazy_author) # preload authors prepare_commits_for_rendering(limited) end # rubocop: enable Gitlab/ModuleWithInstanceVariables diff --git a/app/controllers/concerns/routable_actions.rb b/app/controllers/concerns/routable_actions.rb index 45f9888a040..1b2e6461dee 100644 --- a/app/controllers/concerns/routable_actions.rb +++ b/app/controllers/concerns/routable_actions.rb @@ -47,7 +47,7 @@ module RoutableActions canonical_path = routable.full_path if canonical_path != requested_full_path - if canonical_path.casecmp(requested_full_path) != 0 + if !request.xhr? && request.format.html? && canonical_path.casecmp(requested_full_path) != 0 flash[:notice] = "#{routable.class.to_s.titleize} '#{requested_full_path}' was moved to '#{canonical_path}'. Please update any links and bookmarks that may still have the old path." end diff --git a/app/controllers/concerns/sourcegraph_gon.rb b/app/controllers/concerns/sourcegraph_gon.rb new file mode 100644 index 00000000000..ab4abd734fb --- /dev/null +++ b/app/controllers/concerns/sourcegraph_gon.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module SourcegraphGon + extend ActiveSupport::Concern + + included do + before_action :push_sourcegraph_gon, unless: :json_request? + end + + private + + def push_sourcegraph_gon + return unless sourcegraph_enabled? + + gon.push({ + sourcegraph: { url: Gitlab::CurrentSettings.sourcegraph_url } + }) + end + + def sourcegraph_enabled? + Gitlab::CurrentSettings.sourcegraph_enabled && sourcegraph_enabled_for_project? && current_user&.sourcegraph_enabled + end + + def sourcegraph_enabled_for_project? + return false unless project && Gitlab::Sourcegraph.feature_enabled?(project) + return project.public? if Gitlab::CurrentSettings.sourcegraph_public_only + + true + end +end diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 80c0a0d88a8..ebee8e9094e 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -22,7 +22,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController respond_to do |format| format.html do redirect_to dashboard_todos_path, - status: 302, + status: :found, notice: _('To-do item successfully marked as done.') end format.js { head :ok } @@ -34,7 +34,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController updated_ids = TodoService.new.mark_todos_as_done(@todos, current_user) respond_to do |format| - format.html { redirect_to dashboard_todos_path, status: 302, notice: _('Everything on your to-do list is marked as done.') } + format.html { redirect_to dashboard_todos_path, status: :found, notice: _('Everything on your to-do list is marked as done.') } format.js { head :ok } format.json { render json: todos_counts.merge(updated_ids: updated_ids) } end diff --git a/app/controllers/groups/boards_controller.rb b/app/controllers/groups/boards_controller.rb index 3c86f3108ab..8c9bf17f017 100644 --- a/app/controllers/groups/boards_controller.rb +++ b/app/controllers/groups/boards_controller.rb @@ -6,7 +6,7 @@ class Groups::BoardsController < Groups::ApplicationController before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:multi_select_board) + push_frontend_feature_flag(:multi_select_board, default_enabled: true) end private diff --git a/app/controllers/groups/group_links_controller.rb b/app/controllers/groups/group_links_controller.rb new file mode 100644 index 00000000000..7965311c5f1 --- /dev/null +++ b/app/controllers/groups/group_links_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class Groups::GroupLinksController < Groups::ApplicationController + before_action :check_feature_flag! + before_action :authorize_admin_group! + + def create + shared_with_group = Group.find(params[:shared_with_group_id]) if params[:shared_with_group_id].present? + + if shared_with_group + result = Groups::GroupLinks::CreateService + .new(shared_with_group, current_user, group_link_create_params) + .execute(group) + + return render_404 if result[:http_status] == 404 + + flash[:alert] = result[:message] if result[:status] == :error + else + flash[:alert] = _('Please select a group.') + end + + redirect_to group_group_members_path(group) + end + + private + + def group_link_create_params + params.permit(:shared_group_access, :expires_at) + end + + def check_feature_flag! + render_404 unless Feature.enabled?(:share_group_with_group) + end +end diff --git a/app/controllers/groups/labels_controller.rb b/app/controllers/groups/labels_controller.rb index 26768c628ca..1034ca6cd7b 100644 --- a/app/controllers/groups/labels_controller.rb +++ b/app/controllers/groups/labels_controller.rb @@ -63,7 +63,7 @@ class Groups::LabelsController < Groups::ApplicationController respond_to do |format| format.html do - redirect_to group_labels_path(@group), status: 302, notice: "#{@label.name} deleted permanently" + redirect_to group_labels_path(@group), status: :found, notice: "#{@label.name} deleted permanently" end format.js end diff --git a/app/controllers/groups/registry/repositories_controller.rb b/app/controllers/groups/registry/repositories_controller.rb index e09a9e6eb21..cfddd8a3ba9 100644 --- a/app/controllers/groups/registry/repositories_controller.rb +++ b/app/controllers/groups/registry/repositories_controller.rb @@ -16,7 +16,7 @@ module Groups render json: ContainerRepositoriesSerializer .new(current_user: current_user) - .represent(@images) + .represent_read_only(@images) end end end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 35e364abba3..755d97b091c 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -6,6 +6,7 @@ class GroupsController < Groups::ApplicationController include ParamsBackwardCompatibility include PreviewMarkdown include RecordUserLastActivity + extend ::Gitlab::Utils::Override respond_to :html @@ -24,6 +25,10 @@ class GroupsController < Groups::ApplicationController before_action :user_actions, only: [:show] + before_action do + push_frontend_feature_flag(:vue_issuables_list, @group) + end + skip_cross_project_access_check :index, :new, :create, :edit, :update, :destroy, :projects # When loading show as an atom feed, we render events that could leak cross @@ -111,7 +116,7 @@ class GroupsController < Groups::ApplicationController def destroy Groups::DestroyService.new(@group, current_user).async_execute - redirect_to root_path, status: 302, alert: "Group '#{@group.name}' was scheduled for deletion." + redirect_to root_path, status: :found, alert: "Group '#{@group.name}' was scheduled for deletion." end # rubocop: disable CodeReuse/ActiveRecord @@ -233,6 +238,11 @@ class GroupsController < Groups::ApplicationController @group.self_and_descendants.public_or_visible_to_user(current_user) end end + + override :markdown_service_params + def markdown_service_params + params.merge(group: group) + end end GroupsController.prepend_if_ee('EE::GroupsController') diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index efd5f0fc607..c6a02250896 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -5,6 +5,11 @@ class HealthController < ActionController::Base include RequiresWhitelistedMonitoringClient CHECKS = [ + Gitlab::HealthChecks::MasterCheck + ].freeze + + ALL_CHECKS = [ + *CHECKS, Gitlab::HealthChecks::DbCheck, Gitlab::HealthChecks::Redis::RedisCheck, Gitlab::HealthChecks::Redis::CacheCheck, @@ -14,8 +19,9 @@ class HealthController < ActionController::Base ].freeze def readiness - # readiness check is a collection with all above application-level checks - render_checks(*CHECKS) + # readiness check is a collection of application-level checks + # and optionally all service checks + render_checks(params[:all] ? ALL_CHECKS : CHECKS) end def liveness @@ -25,7 +31,7 @@ class HealthController < ActionController::Base private - def render_checks(*checks) + def render_checks(checks = []) result = Gitlab::HealthChecks::Probes::Collection .new(*checks) .execute diff --git a/app/controllers/help_controller.rb b/app/controllers/help_controller.rb index a58235790ad..97895d6461c 100644 --- a/app/controllers/help_controller.rb +++ b/app/controllers/help_controller.rb @@ -36,7 +36,7 @@ class HelpController < ApplicationController render 'show.html.haml' else # Force template to Haml - render 'errors/not_found.html.haml', layout: 'errors', status: 404 + render 'errors/not_found.html.haml', layout: 'errors', status: :not_found end end diff --git a/app/controllers/ldap/omniauth_callbacks_controller.rb b/app/controllers/ldap/omniauth_callbacks_controller.rb index 4d8875937eb..71a88bf3395 100644 --- a/app/controllers/ldap/omniauth_callbacks_controller.rb +++ b/app/controllers/ldap/omniauth_callbacks_controller.rb @@ -4,7 +4,7 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController extend ::Gitlab::Utils::Override def self.define_providers! - return unless Gitlab::Auth::LDAP::Config.enabled? + return unless Gitlab::Auth::LDAP::Config.sign_in_enabled? Gitlab::Auth::LDAP::Config.available_servers.each do |server| alias_method server['provider_name'], :ldap @@ -14,6 +14,8 @@ class Ldap::OmniauthCallbacksController < OmniauthCallbacksController # We only find ourselves here # if the authentication to LDAP was successful. def ldap + return unless Gitlab::Auth::LDAP::Config.sign_in_enabled? + sign_in_user_flow(Gitlab::Auth::LDAP::User) end diff --git a/app/controllers/notification_settings_controller.rb b/app/controllers/notification_settings_controller.rb index c97fec0a6ee..e5d4a4bb073 100644 --- a/app/controllers/notification_settings_controller.rb +++ b/app/controllers/notification_settings_controller.rb @@ -16,12 +16,7 @@ class NotificationSettingsController < ApplicationController @notification_setting = current_user.notification_settings.find(params[:id]) @saved = @notification_setting.update(notification_setting_params_for(@notification_setting.source)) - if params[:hide_label].present? - btn_class = params[:project_id].present? ? 'btn-xs' : '' - render_response("shared/notifications/_new_button", btn_class) - else - render_response - end + render_response end private @@ -42,7 +37,16 @@ class NotificationSettingsController < ApplicationController can?(current_user, ability_name, resource) end - def render_response(response_template = "shared/notifications/_button", btn_class = nil) + def render_response + btn_class = nil + + if params[:hide_label].present? + btn_class = 'btn-xs' if params[:project_id].present? + response_template = 'shared/notifications/_new_button' + else + response_template = 'shared/notifications/_button' + end + render json: { html: view_to_html_string(response_template, notification_setting: @notification_setting, btn_class: btn_class), saved: @saved diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 12dc2d1af1c..8dd51ce1d64 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -57,7 +57,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController end rescue_from ActiveRecord::RecordNotFound do |exception| - render "errors/not_found", layout: "errors", status: 404 + render "errors/not_found", layout: "errors", status: :not_found end def create_application_params diff --git a/app/controllers/oauth/authorized_applications_controller.rb b/app/controllers/oauth/authorized_applications_controller.rb index a59ade559b3..9cfa57c53a5 100644 --- a/app/controllers/oauth/authorized_applications_controller.rb +++ b/app/controllers/oauth/authorized_applications_controller.rb @@ -13,7 +13,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio end redirect_to applications_profile_url, - status: 302, + status: :found, notice: I18n.t(:notice, scope: [:doorkeeper, :flash, :authorized_applications, :destroy]) end end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index b992972dfb8..eca58748cc5 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -47,7 +47,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController def omniauth_error @provider = params[:provider] @error = params[:error] - render 'errors/omniauth_error', layout: "oauth_error", status: 422 + render 'errors/omniauth_error', layout: "oauth_error", status: :unprocessable_entity end def cas3 diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 42d4d785174..214640a5295 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -47,7 +47,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController :preferred_language, :time_display_relative, :time_format_in_24h, - :show_whitespace_in_diffs + :show_whitespace_in_diffs, + :sourcegraph_enabled ] end end diff --git a/app/controllers/profiles/u2f_registrations_controller.rb b/app/controllers/profiles/u2f_registrations_controller.rb index 866c4dee6e2..84ce4a56e64 100644 --- a/app/controllers/profiles/u2f_registrations_controller.rb +++ b/app/controllers/profiles/u2f_registrations_controller.rb @@ -4,6 +4,6 @@ class Profiles::U2fRegistrationsController < Profiles::ApplicationController def destroy u2f_registration = current_user.u2f_registrations.find(params[:id]) u2f_registration.destroy - redirect_to profile_two_factor_auth_path, status: 302, notice: _("Successfully deleted U2F device.") + redirect_to profile_two_factor_auth_path, status: :found, notice: _("Successfully deleted U2F device.") end end diff --git a/app/controllers/projects/blame_controller.rb b/app/controllers/projects/blame_controller.rb index 9076bdb9f04..92655d593dd 100644 --- a/app/controllers/projects/blame_controller.rb +++ b/app/controllers/projects/blame_controller.rb @@ -3,6 +3,7 @@ # Controller for viewing a file's blame class Projects::BlameController < Projects::ApplicationController include ExtractsPath + include RedirectsForMissingPathOnTree before_action :require_non_empty_project before_action :assign_ref_vars @@ -11,7 +12,9 @@ class Projects::BlameController < Projects::ApplicationController def show @blob = @repository.blob_at(@commit.id, @path) - return render_404 unless @blob + unless @blob + return redirect_to_tree_root_for_missing_path(@project, @ref, @path) + end environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 7c3d43fb49a..7c97f771a70 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -7,6 +7,9 @@ class Projects::BlobController < Projects::ApplicationController include RendersBlob include NotesHelper include ActionView::Helpers::SanitizeHelper + include RedirectsForMissingPathOnTree + include SourcegraphGon + prepend_before_action :authenticate_user!, only: [:edit] around_action :allow_gitaly_ref_name_caching, only: [:show] @@ -119,7 +122,7 @@ class Projects::BlobController < Projects::ApplicationController end end - return render_404 + return redirect_to_tree_root_for_missing_path(@project, @ref, @path) end end diff --git a/app/controllers/projects/boards_controller.rb b/app/controllers/projects/boards_controller.rb index 3b335fa4af4..db05da0bb7f 100644 --- a/app/controllers/projects/boards_controller.rb +++ b/app/controllers/projects/boards_controller.rb @@ -8,7 +8,7 @@ class Projects::BoardsController < Projects::ApplicationController before_action :authorize_read_board!, only: [:index, :show] before_action :assign_endpoint_vars before_action do - push_frontend_feature_flag(:multi_select_board) + push_frontend_feature_flag(:multi_select_board, default_enabled: true) end private diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 939a09d4fd2..afb670b687b 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -8,6 +8,7 @@ class Projects::CommitController < Projects::ApplicationController include CreatesCommit include DiffForPath include DiffHelper + include SourcegraphGon # Authorize before_action :require_non_empty_project diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index c053ca19a94..4562296cea0 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -13,8 +13,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] before_action only: [:metrics, :additional_metrics, :metrics_dashboard] do - push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint, default_enabled: true) - push_frontend_feature_flag(:environment_metrics_additional_panel_types) push_frontend_feature_flag(:prometheus_computed_alerts) end @@ -133,7 +131,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController if environment redirect_to environment_metrics_path(environment) else - render :empty + render :empty_metrics end end @@ -199,8 +197,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def metrics_dashboard_params params - .permit(:embedded, :group, :title, :y_label) - .to_h.symbolize_keys + .permit(:embedded, :group, :title, :y_label, :dashboard_path, :environment) .merge(dashboard_path: params[:dashboard], environment: environment) end diff --git a/app/controllers/projects/error_tracking_controller.rb b/app/controllers/projects/error_tracking_controller.rb index 88d0755f41f..9dea6b663ea 100644 --- a/app/controllers/projects/error_tracking_controller.rb +++ b/app/controllers/projects/error_tracking_controller.rb @@ -15,6 +15,23 @@ class Projects::ErrorTrackingController < Projects::ApplicationController end end + def details + respond_to do |format| + format.html + format.json do + render_issue_detail_json + end + end + end + + def stack_trace + respond_to do |format| + format.json do + render_issue_stack_trace_json + end + end + end + def list_projects respond_to do |format| format.json do @@ -29,10 +46,7 @@ class Projects::ErrorTrackingController < Projects::ApplicationController service = ErrorTracking::ListIssuesService.new(project, current_user) result = service.execute - unless result[:status] == :success - return render json: { message: result[:message] }, - status: result[:http_status] || :bad_request - end + return if handle_errors(result) render json: { errors: serialize_errors(result[:issues]), @@ -40,6 +54,28 @@ class Projects::ErrorTrackingController < Projects::ApplicationController } end + def render_issue_detail_json + service = ErrorTracking::IssueDetailsService.new(project, current_user, issue_details_params) + result = service.execute + + return if handle_errors(result) + + render json: { + error: serialize_detailed_error(result[:issue]) + } + end + + def render_issue_stack_trace_json + service = ErrorTracking::IssueLatestEventService.new(project, current_user, issue_details_params) + result = service.execute + + return if handle_errors(result) + + render json: { + error: serialize_error_event(result[:latest_event]) + } + end + def render_project_list_json service = ErrorTracking::ListProjectsService.new( project, @@ -62,10 +98,21 @@ class Projects::ErrorTrackingController < Projects::ApplicationController end end + def handle_errors(result) + unless result[:status] == :success + render json: { message: result[:message] }, + status: result[:http_status] || :bad_request + end + end + def list_projects_params params.require(:error_tracking_setting).permit([:api_host, :token]) end + def issue_details_params + params.permit(:issue_id) + end + def set_polling_interval Gitlab::PollingInterval.set_header(response, interval: POLLING_INTERVAL) end @@ -76,6 +123,18 @@ class Projects::ErrorTrackingController < Projects::ApplicationController .represent(errors) end + def serialize_detailed_error(error) + ErrorTracking::DetailedErrorSerializer + .new(project: project, user: current_user) + .represent(error) + end + + def serialize_error_event(event) + ErrorTracking::ErrorEventSerializer + .new(project: project, user: current_user) + .represent(event) + end + def serialize_projects(projects) ErrorTracking::ProjectSerializer .new(project: project, user: current_user) diff --git a/app/controllers/projects/grafana_api_controller.rb b/app/controllers/projects/grafana_api_controller.rb index 4bdf4c12cac..380a18818ab 100644 --- a/app/controllers/projects/grafana_api_controller.rb +++ b/app/controllers/projects/grafana_api_controller.rb @@ -2,6 +2,7 @@ class Projects::GrafanaApiController < Projects::ApplicationController include RenderServiceResults + include MetricsDashboard def proxy result = ::Grafana::ProxyService.new( @@ -19,6 +20,10 @@ class Projects::GrafanaApiController < Projects::ApplicationController private + def metrics_dashboard_params + params.permit(:embedded, :grafana_url) + end + def query_params params.permit(:query, :start, :end, :step) end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 96cb400950b..009765702ab 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:vue_issuable_sidebar, project.group) + push_frontend_feature_flag(:release_search_filter, project) end respond_to :html diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index 386a1f00bd2..b7aeab8f5ff 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -76,7 +76,7 @@ class Projects::LabelsController < Projects::ApplicationController @labels = find_labels redirect_to project_labels_path(@project), - status: 302, + status: :found, notice: 'Label was removed' end diff --git a/app/controllers/projects/lfs_api_controller.rb b/app/controllers/projects/lfs_api_controller.rb index a1983bc5462..1273c55b83a 100644 --- a/app/controllers/projects/lfs_api_controller.rb +++ b/app/controllers/projects/lfs_api_controller.rb @@ -109,7 +109,7 @@ class Projects::LfsApiController < Projects::GitHttpClientController message: lfs_read_only_message }, content_type: LfsRequest::CONTENT_TYPE, - status: 403 + status: :forbidden ) end end diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 808265634da..78dc196b08e 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -109,7 +109,13 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap @target_project = @merge_request.target_project @source_project = @merge_request.source_project - @commits = set_commits_for_rendering(@merge_request.commits) + + @commits = + set_commits_for_rendering( + @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch), + commits_count: @merge_request.commits_count + ) + @commit = @merge_request.diff_head_commit # FIXME: We have to assign a presenter to another instance variable diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 4a37dfe5c19..42f9c0522a3 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -31,6 +31,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic options = { merge_request: @merge_request, + diff_view: diff_view, pagination_data: diffs.pagination_data } @@ -60,7 +61,9 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic render: ->(partial, locals) { view_to_html_string(partial, locals) } } - render json: DiffsSerializer.new(request).represent(@diffs, additional_attributes) + options = additional_attributes.merge(diff_view: diff_view) + + render json: DiffsSerializer.new(request).represent(@diffs, options) end def define_diff_vars diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index ff199e05e99..766ec1e33f3 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -9,11 +9,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include ToggleAwardEmoji include IssuableCollections include RecordUserLastActivity + include SourcegraphGon 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_test_reports!, only: [:test_reports] + before_action :authorize_read_actual_head_pipeline!, only: [:test_reports, :exposed_artifacts] 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] @@ -23,6 +24,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) + push_frontend_feature_flag(:release_search_filter, @project) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -89,7 +91,10 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo # Get commits from repository # or from cache if already merged @commits = - set_commits_for_rendering(@merge_request.commits.with_latest_pipeline) + set_commits_for_rendering( + @merge_request.recent_commits.with_latest_pipeline(@merge_request.source_branch), + commits_count: @merge_request.commits_count + ) render json: { html: view_to_html_string('projects/merge_requests/_commits') } end @@ -115,6 +120,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo reports_response(@merge_request.compare_test_reports) end + def exposed_artifacts + if @merge_request.has_exposed_artifacts? + reports_response(@merge_request.find_exposed_artifacts) + else + head :no_content + end + end + def edit define_edit_vars end @@ -218,6 +231,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @merge_request.rebase_async(current_user.id) head :ok + rescue MergeRequest::RebaseLockTimeout => e + render json: { merge_error: e.message }, status: :conflict end def discussions @@ -241,7 +256,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def merge_params_attributes - [:should_remove_source_branch, :commit_message, :squash_commit_message, :squash, :auto_merge_strategy] + MergeRequest::KNOWN_MERGE_PARAMS end def auto_merge_requested? @@ -281,7 +296,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo return :sha_mismatch if params[:sha] != @merge_request.diff_head_sha - @merge_request.update(merge_error: nil, squash: merge_params.fetch(:squash, false)) + @merge_request.update(merge_error: nil, squash: params.fetch(:squash, false)) if auto_merge_requested? if merge_request.auto_merge_enabled? @@ -353,12 +368,11 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo when :error render json: { status_reason: report_comparison[:status_reason] }, status: :bad_request else - render json: { status_reason: 'Unknown error' }, status: :internal_server_error + raise "Failed to build comparison response as comparison yielded unknown status '#{report_comparison[:status]}'" end end - def authorize_test_reports! - # MergeRequest#actual_head_pipeline is the pipeline accessed in MergeRequest#compare_reports. + def authorize_read_actual_head_pipeline! return render_404 unless can?(current_user, :read_build, merge_request.actual_head_pipeline) end end diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb index 73e629ab7c3..722fc30b3ff 100644 --- a/app/controllers/projects/pages_controller.rb +++ b/app/controllers/projects/pages_controller.rb @@ -21,7 +21,7 @@ class Projects::PagesController < Projects::ApplicationController respond_to do |format| format.html do redirect_to project_pages_path(@project), - status: 302, + status: :found, notice: 'Pages were removed' end end diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb index c287e440db0..b693642981e 100644 --- a/app/controllers/projects/pages_domains_controller.rb +++ b/app/controllers/projects/pages_domains_controller.rb @@ -8,6 +8,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController before_action :domain, except: [:new, :create] def show + redirect_to edit_project_pages_domain_path(@project, @domain) end def new @@ -23,7 +24,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController flash[:alert] = 'Failed to verify domain ownership' end - redirect_to project_pages_domain_path(@project, @domain) + redirect_to edit_project_pages_domain_path(@project, @domain) end def edit @@ -33,7 +34,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController @domain = @project.pages_domains.create(create_params) if @domain.valid? - redirect_to project_pages_domain_path(@project, @domain) + redirect_to edit_project_pages_domain_path(@project, @domain) else render 'new' end @@ -42,7 +43,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController def update if @domain.update(update_params) redirect_to project_pages_path(@project), - status: 302, + status: :found, notice: 'Domain was updated' else render 'edit' @@ -55,13 +56,21 @@ class Projects::PagesDomainsController < Projects::ApplicationController respond_to do |format| format.html do redirect_to project_pages_path(@project), - status: 302, + status: :found, notice: 'Domain was removed' end format.js end end + def clean_certificate + unless @domain.update(user_provided_certificate: nil, user_provided_key: nil) + flash[:alert] = @domain.errors.full_messages.join(', ') + end + + redirect_to edit_project_pages_domain_path(@project, @domain) + end + private def create_params @@ -69,7 +78,7 @@ class Projects::PagesDomainsController < Projects::ApplicationController end def update_params - params.require(:pages_domain).permit(:user_provided_key, :user_provided_certificate, :auto_ssl_enabled) + params.fetch(:pages_domain, {}).permit(:user_provided_key, :user_provided_certificate, :auto_ssl_enabled) end def domain diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 106ef1b72c1..4d35353d5f5 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(:hide_dismissed_vulnerabilities) + push_frontend_feature_flag(:junit_pipeline_view) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show] @@ -156,14 +157,21 @@ class Projects::PipelinesController < Projects::ApplicationController def test_report return unless Feature.enabled?(:junit_pipeline_view, project) - if pipeline_test_report == :error - render json: { status: :error_parsing_report } - return - end + respond_to do |format| + format.html do + render 'show' + end - render json: TestReportSerializer - .new(current_user: @current_user) - .represent(pipeline_test_report) + format.json do + if pipeline_test_report == :error + render json: { status: :error_parsing_report } + else + render json: TestReportSerializer + .new(current_user: @current_user) + .represent(pipeline_test_report) + end + end + end end private diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index 717df9f09e0..72c82aec31d 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -2,12 +2,48 @@ class Projects::ReleasesController < Projects::ApplicationController # Authorize - before_action :require_non_empty_project + before_action :require_non_empty_project, except: [:index] + before_action :release, only: %i[edit update] before_action :authorize_read_release! before_action do - push_frontend_feature_flag(:release_edit_page, project) + push_frontend_feature_flag(:release_edit_page, project, default_enabled: true) + push_frontend_feature_flag(:release_issue_summary, project) end + before_action :authorize_update_release!, only: %i[edit update] def index + respond_to do |format| + format.html do + require_non_empty_project + end + format.json { render json: releases } + end + end + + protected + + def releases + ReleasesFinder.new(@project, current_user).execute + end + + def edit + respond_to do |format| + format.html { render 'edit' } + end + end + + private + + def authorize_update_release! + access_denied! unless Feature.enabled?(:release_edit_page, project, default_enabled: true) + access_denied! unless can?(current_user, :update_release, release) + end + + def release + @release ||= project.releases.find_by_tag!(sanitized_tag_name) + end + + def sanitized_tag_name + CGI.unescape(params[:tag]) end end diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index 5bf3618b389..1571cb8cd34 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -70,7 +70,7 @@ module Projects project: [:slug, :name, :organization_slug, :organization_name] ], - grafana_integration_attributes: [:token, :grafana_url] + grafana_integration_attributes: [:token, :grafana_url, :enabled] } end end diff --git a/app/controllers/projects/tags_controller.rb b/app/controllers/projects/tags_controller.rb index 7d9387b1d94..c89bfd110c4 100644 --- a/app/controllers/projects/tags_controller.rb +++ b/app/controllers/projects/tags_controller.rb @@ -84,7 +84,7 @@ class Projects::TagsController < Projects::ApplicationController format.html do redirect_to project_tags_path(@project), - alert: @error, status: 303 + alert: @error, status: :see_other end format.js do diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 7509cc29a76..eec89afe354 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -5,6 +5,7 @@ class Projects::TreeController < Projects::ApplicationController include ExtractsPath include CreatesCommit include ActionView::Helpers::SanitizeHelper + include RedirectsForMissingPathOnTree around_action :allow_gitaly_ref_name_caching, only: [:show] @@ -19,12 +20,9 @@ class Projects::TreeController < Projects::ApplicationController if tree.entries.empty? if @repository.blob_at(@commit.id, @path) - return redirect_to( - project_blob_path(@project, - File.join(@ref, @path)) - ) + return redirect_to project_blob_path(@project, File.join(@ref, @path)) elsif @path.present? - return render_404 + return redirect_to_tree_root_for_missing_path(@project, @ref, @path) end end diff --git a/app/controllers/projects/usage_ping_controller.rb b/app/controllers/projects/usage_ping_controller.rb new file mode 100644 index 00000000000..ebdf28bd59c --- /dev/null +++ b/app/controllers/projects/usage_ping_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Projects::UsagePingController < Projects::ApplicationController + before_action :authenticate_user! + + def web_ide_clientside_preview + return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled? + + Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_count + + head(200) + end +end diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index b187fdb2723..fb06299676c 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -110,7 +110,7 @@ class Projects::WikisController < Projects::ApplicationController WikiPages::DestroyService.new(@project, current_user).execute(@page) redirect_to project_wiki_path(@project, :home), - status: 302, + status: :found, notice: _("Page was successfully deleted") rescue Gitlab::Git::Wiki::OperationError => e @error = e diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index abd19df9a3d..e5dea031bb5 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -154,7 +154,7 @@ class ProjectsController < Projects::ApplicationController redirect_to dashboard_projects_path, status: :found rescue Projects::DestroyService::DestroyError => ex - redirect_to edit_project_path(@project), status: 302, alert: ex.message + redirect_to edit_project_path(@project), status: :found, alert: ex.message end def new_issuable_address @@ -371,6 +371,7 @@ class ProjectsController < Projects::ApplicationController :path, :printing_merge_request_link_enabled, :public_builds, + :remove_source_branch_after_merge, :request_access_enabled, :runners_token, :tag_list, diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index 4a746fc915d..5fc7f5c84f0 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -8,7 +8,7 @@ class RegistrationsController < Devise::RegistrationsController layout :choose_layout - skip_before_action :require_role, only: [:welcome, :update_role] + skip_before_action :required_signup_info, only: [:welcome, :update_registration] prepend_before_action :check_captcha, only: :create before_action :whitelist_query_limiting, only: [:destroy] before_action :ensure_terms_accepted, @@ -16,6 +16,7 @@ class RegistrationsController < Devise::RegistrationsController def new if experiment_enabled?(:signup_flow) + track_experiment_event(:signup_flow, 'start') # We want this event to be tracked when the user is _in_ the experimental group @resource = build_resource else redirect_to new_user_session_path(anchor: 'register-pane') @@ -23,6 +24,8 @@ class RegistrationsController < Devise::RegistrationsController end def create + track_experiment_event(:signup_flow, 'end') unless experiment_enabled?(:signup_flow) # We want this event to be tracked when the user is _in_ the control group + accept_pending_invitations super do |new_user| @@ -42,29 +45,30 @@ class RegistrationsController < Devise::RegistrationsController if destroy_confirmation_valid? current_user.delete_async(deleted_by: current_user) session.try(:destroy) - redirect_to new_user_session_path, status: 303, notice: s_('Profiles|Account scheduled for removal.') + redirect_to new_user_session_path, status: :see_other, notice: s_('Profiles|Account scheduled for removal.') else - redirect_to profile_account_path, status: 303, alert: destroy_confirmation_failure_message + redirect_to profile_account_path, status: :see_other, alert: destroy_confirmation_failure_message end end def welcome return redirect_to new_user_registration_path unless current_user - return redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) if current_user.role.present? + return redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) if current_user.role.present? && !current_user.setup_for_company.nil? - current_user.name = nil + current_user.name = nil if current_user.name == current_user.username render layout: 'devise_experimental_separate_sign_up_flow' end - def update_role - user_params = params.require(:user).permit(:name, :role) - result = ::Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute + def update_registration + user_params = params.require(:user).permit(:name, :role, :setup_for_company) + result = ::Users::SignupService.new(current_user, user_params).execute if result[:status] == :success + track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group set_flash_message! :notice, :signed_up redirect_to stored_location_or_dashboard_or_almost_there_path(current_user) else - redirect_to users_sign_up_welcome_path, alert: result[:message] + render :welcome, layout: 'devise_experimental_separate_sign_up_flow' end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 1c506065b56..0007d5826ba 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -24,6 +24,7 @@ class SessionsController < Devise::SessionsController before_action :store_unauthenticated_sessions, only: [:new] before_action :save_failed_login, if: :action_new_and_failed_login? before_action :load_recaptcha + before_action :frontend_tracking_data, only: [:new] after_action :log_failed_login, if: :action_new_and_failed_login? @@ -269,7 +270,13 @@ class SessionsController < Devise::SessionsController end def ldap_servers - @ldap_servers ||= Gitlab::Auth::LDAP::Config.available_servers + @ldap_servers ||= begin + if Gitlab::Auth::LDAP::Config.sign_in_enabled? + Gitlab::Auth::LDAP::Config.available_servers + else + [] + end + end end def unverified_anonymous_user? @@ -293,6 +300,11 @@ class SessionsController < Devise::SessionsController "standard" end end + + def frontend_tracking_data + # We want tracking data pushed to the frontend when the user is _in_ the control group + frontend_experimentation_tracking_data(:signup_flow, 'start') unless experiment_enabled?(:signup_flow) + end end SessionsController.prepend_if_ee('EE::SessionsController') diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index c3c227b08c5..06374736dcf 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -16,7 +16,7 @@ class UsersController < ApplicationController skip_before_action :authenticate_user! prepend_before_action(only: [:show]) { authenticate_sessionless_user!(:rss) } - before_action :user, except: [:exists] + before_action :user, except: [:exists, :suggests] before_action :authorize_read_user_profile!, only: [:calendar, :calendar_activities, :groups, :projects, :contributed_projects, :starred_projects, :snippets] @@ -114,6 +114,14 @@ class UsersController < ApplicationController render json: { exists: !!Namespace.find_by_path_or_name(params[:username]) } end + def suggests + namespace_path = params[:username] + exists = !!Namespace.find_by_path_or_name(namespace_path) + suggestions = exists ? [Namespace.clean_path(namespace_path)] : [] + + render json: { exists: exists, suggests: suggestions } + end + private def user diff --git a/app/finders/abuse_reports_finder.rb b/app/finders/abuse_reports_finder.rb new file mode 100644 index 00000000000..04043f36426 --- /dev/null +++ b/app/finders/abuse_reports_finder.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AbuseReportsFinder + attr_reader :params + + def initialize(params = {}) + @params = params + end + + def execute + reports = AbuseReport.all + reports = reports.by_user(params[:user_id]) if params[:user_id].present? + + reports.with_order_id_desc + .with_users + .page(params[:page]) + end +end diff --git a/app/finders/admin/projects_finder.rb b/app/finders/admin/projects_finder.rb index e2b9b0b44c1..53dbf65c43a 100644 --- a/app/finders/admin/projects_finder.rb +++ b/app/finders/admin/projects_finder.rb @@ -12,7 +12,7 @@ class Admin::ProjectsFinder def execute items = Project.without_deleted.with_statistics.with_route items = by_namespace_id(items) - items = by_visibilty_level(items) + items = by_visibility_level(items) items = by_with_push(items) items = by_abandoned(items) items = by_last_repository_check_failed(items) @@ -31,7 +31,7 @@ class Admin::ProjectsFinder end # rubocop: disable CodeReuse/ActiveRecord - def by_visibilty_level(items) + def by_visibility_level(items) params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/branches_finder.rb b/app/finders/branches_finder.rb index 291a24c1405..8001c70a9b2 100644 --- a/app/finders/branches_finder.rb +++ b/app/finders/branches_finder.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true -class BranchesFinder +class BranchesFinder < GitRefsFinder def initialize(repository, params = {}) - @repository = repository - @params = params + super(repository, params) end def execute @@ -15,56 +14,10 @@ class BranchesFinder private - attr_reader :repository, :params - def names @params[:names].presence end - def search - @params[:search].presence - end - - def sort - @params[:sort].presence || 'name' - end - - def by_search(branches) - return branches unless search - - case search - when ->(v) { v.starts_with?('^') } - filter_branches_with_prefix(branches, search.slice(1..-1).upcase) - when ->(v) { v.ends_with?('$') } - filter_branches_with_suffix(branches, search.chop.upcase) - else - matches = filter_branches_by_name(branches, search.upcase) - set_exact_match_as_first_result(matches, search) - end - end - - def filter_branches_with_prefix(branches, prefix) - branches.select { |branch| branch.name.upcase.starts_with?(prefix) } - end - - def filter_branches_with_suffix(branches, suffix) - branches.select { |branch| branch.name.upcase.ends_with?(suffix) } - end - - def filter_branches_by_name(branches, term) - branches.select { |branch| branch.name.upcase.include?(term) } - end - - def set_exact_match_as_first_result(matches, term) - exact_match_index = find_exact_match_index(matches, term) - matches.insert(0, matches.delete_at(exact_match_index)) if exact_match_index - matches - end - - def find_exact_match_index(matches, term) - matches.index { |branch| branch.name.casecmp(term) == 0 } - end - def by_names(branches) return branches unless names diff --git a/app/finders/container_repositories_finder.rb b/app/finders/container_repositories_finder.rb index eb91d7f825b..34921df840b 100644 --- a/app/finders/container_repositories_finder.rb +++ b/app/finders/container_repositories_finder.rb @@ -1,34 +1,38 @@ # frozen_string_literal: true class ContainerRepositoriesFinder - # id: group or project id - # container_type: :group or :project - def initialize(id:, container_type:) - @id = id - @type = container_type.to_sym + VALID_SUBJECTS = [Group, Project].freeze + + def initialize(user:, subject:) + @user = user + @subject = subject end def execute - if project_type? - project.container_repositories - else - group.container_repositories - end + raise ArgumentError, "invalid subject_type" unless valid_subject_type? + return unless authorized? + + return project_repositories if @subject.is_a?(Project) + return group_repositories if @subject.is_a?(Group) end private - attr_reader :id, :type + def valid_subject_type? + VALID_SUBJECTS.include?(@subject.class) + end + + def project_repositories + return unless @subject.container_registry_enabled - def project_type? - type == :project + @subject.container_repositories end - def project - Project.find(id) + def group_repositories + ContainerRepository.for_group_and_its_subgroups(@subject) end - def group - Group.find(id) + def authorized? + Ability.allowed?(@user, :read_container_image, @subject) end end diff --git a/app/finders/git_refs_finder.rb b/app/finders/git_refs_finder.rb new file mode 100644 index 00000000000..2289b34e562 --- /dev/null +++ b/app/finders/git_refs_finder.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class GitRefsFinder + def initialize(repository, params = {}) + @repository = repository + @params = params + end + + protected + + attr_reader :repository, :params + + def search + @params[:search].presence + end + + def sort + @params[:sort].presence || 'name' + end + + def by_search(refs) + return refs unless search + + case search + when ->(v) { v.starts_with?('^') } + filter_refs_with_prefix(refs, search.slice(1..-1)) + when ->(v) { v.ends_with?('$') } + filter_refs_with_suffix(refs, search.chop) + else + matches = filter_refs_by_name(refs, search) + set_exact_match_as_first_result(matches, search) + end + end + + def filter_refs_with_prefix(refs, prefix) + refs.select { |ref| ref.name.upcase.starts_with?(prefix.upcase) } + end + + def filter_refs_with_suffix(refs, suffix) + refs.select { |ref| ref.name.upcase.ends_with?(suffix.upcase) } + end + + def filter_refs_by_name(refs, term) + refs.select { |ref| ref.name.upcase.include?(term.upcase) } + end + + def set_exact_match_as_first_result(matches, term) + exact_match_index = find_exact_match_index(matches, term) + matches.insert(0, matches.delete_at(exact_match_index)) if exact_match_index + matches + end + + def find_exact_match_index(matches, term) + matches.index { |ref| ref.name.casecmp(term) == 0 } + end +end diff --git a/app/finders/group_descendants_finder.rb b/app/finders/group_descendants_finder.rb index 4e489a9c930..1f6829a97d6 100644 --- a/app/finders/group_descendants_finder.rb +++ b/app/finders/group_descendants_finder.rb @@ -80,7 +80,7 @@ class GroupDescendantsFinder if current_user authorized_groups = GroupsFinder.new(current_user, all_available: false) - .execute.as('authorized') + .execute.arel.as('authorized') authorized_to_user = groups_table.project(1).from(authorized_groups) .where(authorized_groups[:id].eq(groups_table[:id])) .exists diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 477093ddadf..dfddd32d7df 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -385,6 +385,9 @@ class IssuableFinder end def count_key(value) + # value may be an array if the finder used in `count_by_state` added an + # additional `group by`. Anyway we are sure that state will be always the + # last item because it's added as the last one to the query. value = Array(value).last klass.available_states.key(value) end @@ -483,6 +486,7 @@ class IssuableFinder # rubocop: disable CodeReuse/ActiveRecord def by_search(items) return items unless search + return items if items.is_a?(ActiveRecord::NullRelation) if use_cte_for_search? cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index df06e68c941..42a15234e57 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -110,7 +110,10 @@ class ProjectsFinder < UnionFinder # rubocop: disable CodeReuse/ActiveRecord def by_ids(items) - project_ids_relation ? items.where(id: project_ids_relation) : items + items = items.where(id: project_ids_relation) if project_ids_relation + items = items.where('id > ?', params[:id_after]) if params[:id_after] + items = items.where('id < ?', params[:id_before]) if params[:id_before] + items end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/finders/prometheus_metrics_finder.rb b/app/finders/prometheus_metrics_finder.rb new file mode 100644 index 00000000000..84a071abbd5 --- /dev/null +++ b/app/finders/prometheus_metrics_finder.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +class PrometheusMetricsFinder + ACCEPTED_PARAMS = [ + :project, + :group, + :title, + :y_label, + :identifier, + :id, + :common, + :ordered + ].freeze + + # Cautiously preferring a memoized class method over a constant + # so that the DB connection is accessed after the class is loaded. + def self.indexes + @indexes ||= PrometheusMetric + .connection + .indexes(:prometheus_metrics) + .map { |index| index.columns.map(&:to_sym) } + end + + def initialize(params = {}) + @params = params.slice(*ACCEPTED_PARAMS) + end + + # @return [PrometheusMetric, PrometheusMetric::ActiveRecord_Relation] + def execute + validate_params! + + metrics = by_project(::PrometheusMetric.all) + metrics = by_group(metrics) + metrics = by_title(metrics) + metrics = by_y_label(metrics) + metrics = by_common(metrics) + metrics = by_ordered(metrics) + metrics = by_identifier(metrics) + metrics = by_id(metrics) + + metrics + end + + private + + attr_reader :params + + def by_project(metrics) + return metrics unless params[:project] + + metrics.for_project(params[:project]) + end + + def by_group(metrics) + return metrics unless params[:group] + + metrics.for_group(params[:group]) + end + + def by_title(metrics) + return metrics unless params[:title] + + metrics.for_title(params[:title]) + end + + def by_y_label(metrics) + return metrics unless params[:y_label] + + metrics.for_y_label(params[:y_label]) + end + + def by_common(metrics) + return metrics unless params[:common] + + metrics.common + end + + def by_ordered(metrics) + return metrics unless params[:ordered] + + metrics.ordered + end + + def by_identifier(metrics) + return metrics unless params[:identifier] + + metrics.for_identifier(params[:identifier]) + end + + def by_id(metrics) + return metrics unless params[:id] + + metrics.id_in(params[:id]) + end + + def validate_params! + validate_params_present! + validate_id_params! + validate_indexes! + end + + # Ensure all provided params are supported + def validate_params_present! + raise ArgumentError, "Please provide one or more of: #{ACCEPTED_PARAMS}" if params.blank? + end + + # Protect against the caller "finding" the wrong metric + def validate_id_params! + raise ArgumentError, 'Only one of :identifier, :id is permitted' if params[:identifier] && params[:id] + raise ArgumentError, ':identifier must be scoped to a :project or :common' if params[:identifier] && !(params[:project] || params[:common]) + end + + # Protect against unaccounted-for, complex/slow queries. + # This is not a hard and fast rule, but is meant to encourage + # mindful inclusion of new queries. + def validate_indexes! + indexable_params = params.except(:ordered, :id, :project).keys + indexable_params << :project_id if params[:project] + indexable_params.sort! + + raise ArgumentError, "An index should exist for params: #{indexable_params}" unless appropriate_index?(indexable_params) + end + + def appropriate_index?(indexable_params) + return true if indexable_params.blank? + + self.class.indexes.any? { |index| (index - indexable_params).empty? } + end +end diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb index 59e84198fde..72bf968c8ec 100644 --- a/app/finders/releases_finder.rb +++ b/app/finders/releases_finder.rb @@ -6,9 +6,11 @@ class ReleasesFinder @current_user = current_user end - def execute + def execute(preload: true) return Release.none unless Ability.allowed?(@current_user, :read_release, @project) - @project.releases.sorted + releases = @project.releases + releases = releases.preloaded if preload + releases.sorted end end diff --git a/app/finders/tags_finder.rb b/app/finders/tags_finder.rb index 2ffd46245e9..fd58f478b45 100644 --- a/app/finders/tags_finder.rb +++ b/app/finders/tags_finder.rb @@ -1,31 +1,13 @@ # frozen_string_literal: true -class TagsFinder +class TagsFinder < GitRefsFinder def initialize(repository, params) - @repository = repository - @params = params + super(repository, params) end def execute - tags = @repository.tags_sorted_by(sort) - filter_by_name(tags) - end - - private - - def sort - @params[:sort].presence - end - - def search - @params[:search].presence - end - - def filter_by_name(tags) - if search - tags.select { |tag| tag.name.include?(search) } - else - tags - end + tags = repository.tags_sorted_by(sort) + tags = by_search(tags) + tags end end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 2b46e51290f..e56009be33d 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -23,10 +23,16 @@ class TodosFinder NONE = '0' - TODO_TYPES = Set.new(%w(Issue MergeRequest Epic)).freeze + TODO_TYPES = Set.new(%w(Issue MergeRequest)).freeze attr_accessor :current_user, :params + class << self + def todo_types + TODO_TYPES + end + end + def initialize(current_user, params = {}) @current_user = current_user @params = params @@ -111,12 +117,6 @@ class TodosFinder params[:group_id].present? end - def project - strong_memoize(:project) do - Project.find_without_deleted(params[:project_id]) if project? - end - end - def group strong_memoize(:group) do Group.find(params[:group_id]) @@ -124,7 +124,7 @@ class TodosFinder end def type? - type.present? && TODO_TYPES.include?(type) + type.present? && self.class.todo_types.include?(type) end def type @@ -175,7 +175,7 @@ class TodosFinder def by_project(items) if project? - items.for_project(project) + items.for_undeleted_projects.for_project(params[:project_id]) else items end @@ -188,11 +188,9 @@ class TodosFinder end def by_state(items) - if params[:state].to_s == 'done' - items.done - else - items.pending - end + return items.pending if params[:state].blank? + + items.with_states(params[:state]) end def by_type(items) @@ -203,3 +201,5 @@ class TodosFinder end end end + +TodosFinder.prepend_if_ee('EE::TodosFinder') diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 1899278ff3c..a5ddf316572 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -46,7 +46,7 @@ class GitlabSchema < GraphQL::Schema super(query_str, **kwargs) end - def id_from_object(object) + def id_from_object(object, _type = nil, _ctx = nil) unless object.respond_to?(:to_global_id) # This is an error in our schema and needs to be solved. So raise a # more meaningful error message @@ -57,7 +57,7 @@ class GitlabSchema < GraphQL::Schema object.to_global_id end - def object_from_id(global_id) + def object_from_id(global_id, _ctx = nil) gid = GlobalID.parse(global_id) unless gid diff --git a/app/graphql/mutations/merge_requests/set_assignees.rb b/app/graphql/mutations/merge_requests/set_assignees.rb new file mode 100644 index 00000000000..8f0025f0a58 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_assignees.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetAssignees < Base + graphql_name 'MergeRequestSetAssignees' + + argument :assignee_usernames, + [GraphQL::STRING_TYPE], + required: true, + description: <<~DESC + The usernames to assign to the merge request. Replaces existing assignees by default. + DESC + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + description: <<~DESC + The operation to perform. Defaults to REPLACE. + DESC + + def resolve(project_path:, iid:, assignee_usernames:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/36098') + + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + assignee_ids = [] + assignee_ids += merge_request.assignees.map(&:id) if Types::MutationOperationModeEnum.enum.values_at(:remove, :append).include?(operation_mode) + user_ids = UsersFinder.new(current_user, username: assignee_usernames).execute.map(&:id) + + if operation_mode == Types::MutationOperationModeEnum.enum[:remove] + assignee_ids -= user_ids + else + assignee_ids |= user_ids + end + + ::MergeRequests::UpdateService.new(project, current_user, assignee_ids: assignee_ids) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_labels.rb b/app/graphql/mutations/merge_requests/set_labels.rb new file mode 100644 index 00000000000..71f7a353bc9 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_labels.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetLabels < Base + graphql_name 'MergeRequestSetLabels' + + argument :label_ids, + [GraphQL::ID_TYPE], + required: true, + description: <<~DESC + The Label IDs to set. Replaces existing labels by default. + DESC + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + description: <<~DESC + Changes the operation mode. Defaults to REPLACE. + DESC + + def resolve(project_path:, iid:, label_ids:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + label_ids = label_ids + .select(&method(:label_descendant?)) + .map { |gid| GlobalID.parse(gid).model_id } # MergeRequests::UpdateService expects integers + + attribute_name = case operation_mode + when Types::MutationOperationModeEnum.enum[:append] + :add_label_ids + when Types::MutationOperationModeEnum.enum[:remove] + :remove_label_ids + else + :label_ids + end + + ::MergeRequests::UpdateService.new(project, current_user, attribute_name => label_ids) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + + def label_descendant?(gid) + GlobalID.parse(gid)&.model_class&.ancestors&.include?(Label) + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_locked.rb b/app/graphql/mutations/merge_requests/set_locked.rb new file mode 100644 index 00000000000..09aaa0b39aa --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_locked.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetLocked < Base + graphql_name 'MergeRequestSetLocked' + + argument :locked, + GraphQL::BOOLEAN_TYPE, + required: true, + description: <<~DESC + Whether or not to lock the merge request. + DESC + + def resolve(project_path:, iid:, locked:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + ::MergeRequests::UpdateService.new(project, current_user, discussion_locked: locked) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_milestone.rb b/app/graphql/mutations/merge_requests/set_milestone.rb new file mode 100644 index 00000000000..707d6677952 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_milestone.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetMilestone < Base + graphql_name 'MergeRequestSetMilestone' + + argument :milestone_id, + GraphQL::ID_TYPE, + required: false, + loads: Types::MilestoneType, + description: <<~DESC + The milestone to assign to the merge request. + DESC + + def resolve(project_path:, iid:, milestone: nil) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + ::MergeRequests::UpdateService.new(project, current_user, milestone: milestone) + .execute(merge_request) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/set_subscription.rb b/app/graphql/mutations/merge_requests/set_subscription.rb new file mode 100644 index 00000000000..86750152775 --- /dev/null +++ b/app/graphql/mutations/merge_requests/set_subscription.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module MergeRequests + class SetSubscription < Base + graphql_name 'MergeRequestSetSubscription' + + argument :subscribed_state, + GraphQL::BOOLEAN_TYPE, + required: true, + description: 'The desired state of the subscription' + + def resolve(project_path:, iid:, subscribed_state:) + merge_request = authorized_find!(project_path: project_path, iid: iid) + project = merge_request.project + + merge_request.set_subscription(current_user, subscribed_state, project) + + { + merge_request: merge_request, + errors: merge_request.errors.full_messages + } + end + end + end +end diff --git a/app/graphql/mutations/todos/base.rb b/app/graphql/mutations/todos/base.rb new file mode 100644 index 00000000000..b6c7b320be1 --- /dev/null +++ b/app/graphql/mutations/todos/base.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class Base < ::Mutations::BaseMutation + private + + def find_object(id:) + GitlabSchema.object_from_id(id) + end + + def to_global_id(id) + ::URI::GID.build(app: GlobalID.app, model_name: Todo.name, model_id: id, params: nil).to_s + end + end + end +end diff --git a/app/graphql/mutations/todos/mark_done.rb b/app/graphql/mutations/todos/mark_done.rb new file mode 100644 index 00000000000..5483708b5c6 --- /dev/null +++ b/app/graphql/mutations/todos/mark_done.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Todos + class MarkDone < ::Mutations::Todos::Base + graphql_name 'TodoMarkDone' + + authorize :update_todo + + argument :id, + GraphQL::ID_TYPE, + required: true, + description: 'The global id of the todo to mark as done' + + field :todo, Types::TodoType, + null: false, + description: 'The requested todo' + + # rubocop: disable CodeReuse/ActiveRecord + def resolve(id:) + todo = authorized_find!(id: id) + mark_done(Todo.where(id: todo.id)) unless todo.done? + + { + todo: todo.reset, + errors: errors_on_object(todo) + } + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + def mark_done(todo) + TodoService.new.mark_todos_as_done(todo, current_user) + end + end + end +end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb index 5b7eb57841c..85d6b377934 100644 --- a/app/graphql/resolvers/base_resolver.rb +++ b/app/graphql/resolvers/base_resolver.rb @@ -10,6 +10,14 @@ module Resolvers end end + def self.last + @last ||= Class.new(self) do + def resolve(**args) + super.last + end + end + end + def self.resolver_complexity(args, child_complexity:) complexity = 1 complexity += 1 if args[:sort] diff --git a/app/graphql/resolvers/commit_pipelines_resolver.rb b/app/graphql/resolvers/commit_pipelines_resolver.rb new file mode 100644 index 00000000000..92a83523593 --- /dev/null +++ b/app/graphql/resolvers/commit_pipelines_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class CommitPipelinesResolver < BaseResolver + include ::ResolvesPipelines + + alias_method :commit, :object + + def resolve(**args) + resolve_pipelines(commit.project, args.merge!({ sha: commit.sha })) + end + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb index cf43fea45e6..94f6c47e876 100644 --- a/app/graphql/types/base_enum.rb +++ b/app/graphql/types/base_enum.rb @@ -2,5 +2,18 @@ module Types class BaseEnum < GraphQL::Schema::Enum + class << self + def value(*args, **kwargs, &block) + enum[args[0].downcase] = kwargs[:value] || args[0] + + super(*args, **kwargs, &block) + end + + # Returns an indifferent access hash with the key being the downcased name of the attribute + # and the value being the Ruby value (either the explicit `value` passed or the same as the value attr). + def enum + @enum_values ||= {}.with_indifferent_access + end + end end end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index fe71791f413..87f84ec576f 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -8,25 +8,39 @@ module Types present_using CommitPresenter - field :id, type: GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :sha, type: GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :title, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :description, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :message, type: GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :authored_date, type: Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :web_url, type: GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :signature_html, type: GraphQL::STRING_TYPE, - null: true, calls_gitaly: true, description: 'Rendered html for the commit signature' + field :id, type: GraphQL::ID_TYPE, null: false, + description: 'ID (global ID) of the commit' + field :sha, type: GraphQL::STRING_TYPE, null: false, + description: 'SHA1 ID of the commit' + field :title, type: GraphQL::STRING_TYPE, null: true, + description: 'Title of the commit message' + field :description, type: GraphQL::STRING_TYPE, null: true, + description: 'Description of the commit message' + field :message, type: GraphQL::STRING_TYPE, null: true, + description: 'Raw commit message' + field :authored_date, type: Types::TimeType, null: true, + description: 'Timestamp of when the commit was authored' + field :web_url, type: GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the commit' + field :signature_html, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, + description: 'Rendered HTML of the commit signature' + field :author_name, type: GraphQL::STRING_TYPE, null: true, + description: 'Commit authors name' # models/commit lazy loads the author by email - field :author, type: Types::UserType, null: true # rubocop:disable Graphql/Descriptions + field :author, type: Types::UserType, null: true, + description: 'Author of the commit' + + field :pipelines, Types::Ci::PipelineType.connection_type, + null: true, + description: 'Pipelines of the commit ordered latest first', + resolver: Resolvers::CommitPipelinesResolver field :latest_pipeline, type: Types::Ci::PipelineType, null: true, - description: "Latest pipeline for this commit", - resolve: -> (obj, ctx, args) do - Gitlab::Graphql::Loaders::PipelineForShaLoader.new(obj.project, obj.sha).find_last - end + description: "Latest pipeline of the commit", + deprecation_reason: 'use pipelines', + resolver: Resolvers::CommitPipelinesResolver.last end end diff --git a/app/graphql/types/extended_issue_type.rb b/app/graphql/types/extended_issue_type.rb deleted file mode 100644 index e007c1109a3..00000000000 --- a/app/graphql/types/extended_issue_type.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -module Types - class ExtendedIssueType < IssueType - graphql_name 'ExtendedIssue' - - authorize :read_issue - expose_permissions Types::PermissionTypes::Issue - present_using IssuePresenter - - field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5, - description: 'Boolean flag for whether the currently logged in user is subscribed to this issue' - end -end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index 1e52c0cb147..386ae6ed4a3 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -8,14 +8,17 @@ module Types expose_permissions Types::PermissionTypes::Group - field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :web_url, GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the group' - field :avatar_url, GraphQL::STRING_TYPE, null: true, resolve: -> (group, args, ctx) do # rubocop:disable Graphql/Descriptions - group.avatar_url(only_path: false) - end + field :avatar_url, GraphQL::STRING_TYPE, null: true, + description: 'Avatar URL of the group', + resolve: -> (group, args, ctx) do + group.avatar_url(only_path: false) + end - field :parent, GroupType, # rubocop:disable Graphql/Descriptions - null: true, + field :parent, GroupType, null: true, + description: 'Parent group', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, obj.parent_id).find } end end diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb index ad919b55481..48ff5819286 100644 --- a/app/graphql/types/issue_sort_enum.rb +++ b/app/graphql/types/issue_sort_enum.rb @@ -5,6 +5,10 @@ module Types class IssueSortEnum < IssuableSortEnum graphql_name 'IssueSort' description 'Values for sorting issues' + + value 'DUE_DATE_ASC', 'Due date by ascending order', value: 'due_date_asc' + value 'DUE_DATE_DESC', 'Due date by descending order', value: 'due_date_desc' + value 'RELATIVE_POSITION_ASC', 'Relative position by ascending order', value: 'relative_position_asc' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 4965601fe65..4cbb849da3a 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -12,53 +12,79 @@ module Types present_using IssuePresenter - field :iid, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :iid, GraphQL::ID_TYPE, null: false, + description: "Internal ID of the issue" + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the issue' markdown_field :title_html, null: true - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the issue' markdown_field :description_html, null: true - field :state, IssueStateEnum, null: false # rubocop:disable Graphql/Descriptions - - field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference do # rubocop:disable Graphql/Descriptions - argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false # rubocop:disable Graphql/Descriptions + field :state, IssueStateEnum, null: false, + description: 'State of the issue' + + field :reference, GraphQL::STRING_TYPE, null: false, + description: 'Internal reference of the issue. Returned in shortened format by default', + method: :to_reference do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, + description: 'Boolean option specifying whether the reference should be returned in full' end - field :author, Types::UserType, # rubocop:disable Graphql/Descriptions - null: false, + field :author, Types::UserType, null: false, + description: 'User that created the issue', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } # Remove complexity when BatchLoader is used - field :assignees, Types::UserType.connection_type, null: true, complexity: 5 # rubocop:disable Graphql/Descriptions + field :assignees, Types::UserType.connection_type, null: true, complexity: 5, + description: 'Assignees of the issue' # Remove complexity when BatchLoader is used - field :labels, Types::LabelType.connection_type, null: true, complexity: 5 # rubocop:disable Graphql/Descriptions - field :milestone, Types::MilestoneType, # rubocop:disable Graphql/Descriptions - null: true, + field :labels, Types::LabelType.connection_type, null: true, complexity: 5, + description: 'Labels of the issue' + field :milestone, Types::MilestoneType, null: true, + description: 'Milestone of the issue', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } - field :due_date, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :confidential, GraphQL::BOOLEAN_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :discussion_locked, GraphQL::BOOLEAN_TYPE, # rubocop:disable Graphql/Descriptions - null: false, + field :due_date, Types::TimeType, null: true, + description: 'Due date of the issue' + field :confidential, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates the issue is confidential' + field :discussion_locked, GraphQL::BOOLEAN_TYPE, null: false, + description: 'Indicates discussion is locked on the issue', resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked } - field :upvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :downvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :user_notes_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :relative_position, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'List of participants for the issue' - field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate on the issue' - field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the issue' - - field :closed_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - - field :task_completion_status, Types::TaskCompletionStatus, null: false # rubocop:disable Graphql/Descriptions + field :upvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of upvotes the issue has received' + field :downvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of downvotes the issue has received' + field :user_notes_count, GraphQL::INT_TYPE, null: false, + description: 'Number of user notes of the issue' + field :web_path, GraphQL::STRING_TYPE, null: false, method: :issue_path, + description: 'Web path of the issue' + field :web_url, GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the issue' + field :relative_position, GraphQL::INT_TYPE, null: true, + description: 'Relative position of the issue (used for positioning in epic tree and issue boards)' + + field :participants, Types::UserType.connection_type, null: true, complexity: 5, + description: 'List of participants in the issue' + field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5, + description: 'Boolean flag for whether the currently logged in user is subscribed to this issue' + field :time_estimate, GraphQL::INT_TYPE, null: false, + description: 'Time estimate of the issue' + field :total_time_spent, GraphQL::INT_TYPE, null: false, + description: 'Total time reported as spent on the issue' + + field :closed_at, Types::TimeType, null: true, + description: 'Timestamp of when the issue was closed' + + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the issue was created' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of when the issue was last updated' + + field :task_completion_status, Types::TaskCompletionStatus, null: false, + description: 'Task completion status of the issue' end end diff --git a/app/graphql/types/label_type.rb b/app/graphql/types/label_type.rb index 384a27df563..d0bcf2068b7 100644 --- a/app/graphql/types/label_type.rb +++ b/app/graphql/types/label_type.rb @@ -6,10 +6,16 @@ module Types authorize :read_label - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'Label ID' + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the label (markdown rendered as HTML for caching)' markdown_field :description_html, null: true - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :color, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :text_color, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Content of the label' + field :color, GraphQL::STRING_TYPE, null: false, + description: 'Background color of the label' + field :text_color, GraphQL::STRING_TYPE, null: false, + description: 'Text color of the label' end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 71a65dc6713..278a95fe3ca 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -12,70 +12,116 @@ module Types present_using MergeRequestPresenter - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :iid, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the merge request' + field :iid, GraphQL::STRING_TYPE, null: false, + description: 'Internal ID of the merge request' + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the merge request' markdown_field :title_html, null: true - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the merge request (markdown rendered as HTML for caching)' markdown_field :description_html, null: true - field :state, MergeRequestStateEnum, null: false # rubocop:disable Graphql/Descriptions - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :source_project, Types::ProjectType, null: true # rubocop:disable Graphql/Descriptions - field :target_project, Types::ProjectType, null: false # rubocop:disable Graphql/Descriptions - field :diff_refs, Types::DiffRefsType, null: true # rubocop:disable Graphql/Descriptions - # Alias for target_project - field :project, Types::ProjectType, null: false # rubocop:disable Graphql/Descriptions - field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id # rubocop:disable Graphql/Descriptions - field :source_project_id, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :target_project_id, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :source_branch, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :target_branch, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false # rubocop:disable Graphql/Descriptions - field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :diff_head_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_commit_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :user_notes_count, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true # rubocop:disable Graphql/Descriptions - field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true # rubocop:disable Graphql/Descriptions - field :merge_status, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_error, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false # rubocop:disable Graphql/Descriptions - field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false, calls_gitaly: true # rubocop:disable Graphql/Descriptions - # rubocop:disable Graphql/Descriptions - field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage" - # rubocop:enable Graphql/Descriptions - field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false # rubocop:disable Graphql/Descriptions - field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false # rubocop:disable Graphql/Descriptions - field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :upvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :downvotes, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :state, MergeRequestStateEnum, null: false, + description: 'State of the merge request' + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of when the merge request was created' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of when the merge request was last updated' + field :source_project, Types::ProjectType, null: true, + description: 'Source project of the merge request' + field :target_project, Types::ProjectType, null: false, + description: 'Target project of the merge request' + field :diff_refs, Types::DiffRefsType, null: true, + description: 'References of the base SHA, the head SHA, and the start SHA for this merge request' + field :project, Types::ProjectType, null: false, + description: 'Alias for target_project' + field :project_id, GraphQL::INT_TYPE, null: false, method: :target_project_id, + description: 'ID of the merge request project' + field :source_project_id, GraphQL::INT_TYPE, null: true, + description: 'ID of the merge request source project' + field :target_project_id, GraphQL::INT_TYPE, null: false, + description: 'ID of the merge request target project' + field :source_branch, GraphQL::STRING_TYPE, null: false, + description: 'Source branch of the merge request' + field :target_branch, GraphQL::STRING_TYPE, null: false, + description: 'Target branch of the merge request' + field :work_in_progress, GraphQL::BOOLEAN_TYPE, method: :work_in_progress?, null: false, + description: 'Indicates if the merge request is a work in progress (WIP)' + field :merge_when_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the merge has been set to be merged when its pipeline succeeds (MWPS)' + field :diff_head_sha, GraphQL::STRING_TYPE, null: true, + description: 'Diff head SHA of the merge request' + field :merge_commit_sha, GraphQL::STRING_TYPE, null: true, + description: 'SHA of the merge request commit (set once merged)' + field :user_notes_count, GraphQL::INT_TYPE, null: true, + description: 'User notes count of the merge request' + field :should_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :should_remove_source_branch?, null: true, + description: 'Indicates if the source branch of the merge request will be deleted after merge' + field :force_remove_source_branch, GraphQL::BOOLEAN_TYPE, method: :force_remove_source_branch?, null: true, + description: 'Indicates if the project settings will lead to source branch deletion after merge' + field :merge_status, GraphQL::STRING_TYPE, null: true, + description: 'Status of the merge request' + field :in_progress_merge_commit_sha, GraphQL::STRING_TYPE, null: true, + description: 'Commit SHA of the merge request if merge is in progress' + field :merge_error, GraphQL::STRING_TYPE, null: true, + description: 'Error message due to a merge error' + field :allow_collaboration, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if members of the target project can push to the fork' + field :should_be_rebased, GraphQL::BOOLEAN_TYPE, method: :should_be_rebased?, null: false, + description: 'Indicates if the merge request will be rebased' + field :rebase_commit_sha, GraphQL::STRING_TYPE, null: true, + description: 'Rebase commit SHA of the merge request' + field :rebase_in_progress, GraphQL::BOOLEAN_TYPE, method: :rebase_in_progress?, null: false, calls_gitaly: true, + description: 'Indicates if there is a rebase currently in progress for the merge request' + field :merge_commit_message, GraphQL::STRING_TYPE, method: :default_merge_commit_message, null: true, deprecation_reason: "Renamed to defaultMergeCommitMessage", + description: 'Deprecated - renamed to defaultMergeCommitMessage' + field :default_merge_commit_message, GraphQL::STRING_TYPE, null: true, + description: 'Default merge commit message of the merge request' + field :merge_ongoing, GraphQL::BOOLEAN_TYPE, method: :merge_ongoing?, null: false, + description: 'Indicates if a merge is currently occurring' + field :source_branch_exists, GraphQL::BOOLEAN_TYPE, method: :source_branch_exists?, null: false, + description: 'Indicates if the source branch of the merge request exists' + field :mergeable_discussions_state, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if all discussions in the merge request have been resolved, allowing the merge request to be merged' + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL of the merge request' + field :upvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of upvotes for the merge request' + field :downvotes, GraphQL::INT_TYPE, null: false, + description: 'Number of downvotes for the merge request' - field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline # rubocop:disable Graphql/Descriptions - field :pipelines, Types::Ci::PipelineType.connection_type, # rubocop:disable Graphql/Descriptions + field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, + description: 'The pipeline running on the branch HEAD of the merge request' + field :pipelines, Types::Ci::PipelineType.connection_type, + description: 'Pipelines for the merge request', resolver: Resolvers::MergeRequestPipelinesResolver - field :milestone, Types::MilestoneType, description: 'The milestone this merge request is linked to', - null: true, + field :milestone, Types::MilestoneType, null: true, + description: 'The milestone of the merge request', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } - field :assignees, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of assignees for the merge request' - field :participants, Types::UserType.connection_type, null: true, complexity: 5, description: 'The list of participants on the merge request' + field :assignees, Types::UserType.connection_type, null: true, complexity: 5, + description: 'Assignees of the merge request' + field :participants, Types::UserType.connection_type, null: true, complexity: 5, + description: 'Participants in the merge request' field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false, complexity: 5, - description: 'Boolean flag for whether the currently logged in user is subscribed to this MR' - field :labels, Types::LabelType.connection_type, null: true, complexity: 5, description: 'The list of labels on the merge request' - field :discussion_locked, GraphQL::BOOLEAN_TYPE, description: 'Boolean flag determining if comments on the merge request are locked to members only', + description: 'Indicates if the currently logged in user is subscribed to this merge request' + field :labels, Types::LabelType.connection_type, null: true, complexity: 5, + description: 'Labels of the merge request' + field :discussion_locked, GraphQL::BOOLEAN_TYPE, + description: 'Indicates if comments on the merge request are locked to members only', null: false, resolve: -> (obj, _args, _ctx) { !!obj.discussion_locked } - field :time_estimate, GraphQL::INT_TYPE, null: false, description: 'The time estimate for the merge request' - field :total_time_spent, GraphQL::INT_TYPE, null: false, description: 'Total time reported as spent on the merge request' - field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference, description: 'Internal merge request reference. Returned in shortened format by default' do - argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, description: 'Boolean option specifying whether the reference should be returned in full' + field :time_estimate, GraphQL::INT_TYPE, null: false, + description: 'Time estimate of the merge request' + field :total_time_spent, GraphQL::INT_TYPE, null: false, + description: 'Total time reported as spent on the merge request' + field :reference, GraphQL::STRING_TYPE, null: false, method: :to_reference, + description: 'Internal reference of the merge request. Returned in shortened format by default' do + argument :full, GraphQL::BOOLEAN_TYPE, required: false, default_value: false, + description: 'Boolean option specifying whether the reference should be returned in full' end - field :task_completion_status, Types::TaskCompletionStatus, null: false # rubocop:disable Graphql/Descriptions + field :task_completion_status, Types::TaskCompletionStatus, null: false, + description: Types::TaskCompletionStatus.description end end diff --git a/app/graphql/types/metadata_type.rb b/app/graphql/types/metadata_type.rb index bfcb929f5ac..1998b036a53 100644 --- a/app/graphql/types/metadata_type.rb +++ b/app/graphql/types/metadata_type.rb @@ -6,7 +6,9 @@ module Types authorize :read_instance_metadata - field :version, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :revision, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :version, GraphQL::STRING_TYPE, null: false, + description: 'Version' + field :revision, GraphQL::STRING_TYPE, null: false, + description: 'Revision' end end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index 78d0a8220ec..9c3afb28674 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -6,14 +6,23 @@ module Types authorize :read_milestone - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :title, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :state, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the milestone' + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the milestone' + field :title, GraphQL::STRING_TYPE, null: false, + description: 'Title of the milestone' + field :state, GraphQL::STRING_TYPE, null: false, + description: 'State of the milestone' - field :due_date, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :start_date, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions + field :due_date, Types::TimeType, null: true, + description: 'Timestamp of the milestone due date' + field :start_date, Types::TimeType, null: true, + description: 'Timestamp of the milestone start date' - field :created_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions - field :updated_at, Types::TimeType, null: false # rubocop:disable Graphql/Descriptions + field :created_at, Types::TimeType, null: false, + description: 'Timestamp of milestone creation' + field :updated_at, Types::TimeType, null: false, + description: 'Timestamp of last milestone update' end end diff --git a/app/graphql/types/mutation_operation_mode_enum.rb b/app/graphql/types/mutation_operation_mode_enum.rb new file mode 100644 index 00000000000..90a29d2b0e5 --- /dev/null +++ b/app/graphql/types/mutation_operation_mode_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + class MutationOperationModeEnum < BaseEnum + graphql_name 'MutationOperationMode' + description 'Different toggles for changing mutator behavior.' + + # Suggested param name for the enum: `operation_mode` + + value 'REPLACE', 'Performs a replace operation' + value 'APPEND', 'Performs an append operation' + value 'REMOVE', 'Performs a removal operation' + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 17f922a5e54..b3c7c162bb3 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -9,12 +9,18 @@ module Types mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::MergeRequests::SetLabels + mount_mutation Mutations::MergeRequests::SetLocked + mount_mutation Mutations::MergeRequests::SetMilestone + mount_mutation Mutations::MergeRequests::SetSubscription mount_mutation Mutations::MergeRequests::SetWip, calls_gitaly: true + mount_mutation Mutations::MergeRequests::SetAssignees mount_mutation Mutations::Notes::Create::Note, calls_gitaly: true mount_mutation Mutations::Notes::Create::DiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Create::ImageDiffNote, calls_gitaly: true mount_mutation Mutations::Notes::Update mount_mutation Mutations::Notes::Destroy + mount_mutation Mutations::Todos::MarkDone end end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index cc1d06b19e1..1714284a5cf 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -6,27 +6,35 @@ module Types authorize :read_namespace - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the namespace' - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :full_name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :full_path, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Name of the namespace' + field :path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the namespace' + field :full_name, GraphQL::STRING_TYPE, null: false, + description: 'Full name of the namespace' + field :full_path, GraphQL::ID_TYPE, null: false, + description: 'Full path of the namespace' - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Description of the namespace' markdown_field :description_html, null: true - field :visibility, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? # rubocop:disable Graphql/Descriptions - field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :visibility, GraphQL::STRING_TYPE, null: true, + description: 'Visibility of the namespace' + field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled?, + description: 'Indicates if Large File Storage (LFS) is enabled for namespace' + field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if users can request access to namespace' field :root_storage_statistics, Types::RootStorageStatisticsType, null: true, - description: 'The aggregated storage statistics. Only available for root namespaces', + description: 'Aggregated storage statistics of the namespace. Only available for root namespaces', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(obj.id).find } - field :projects, # rubocop:disable Graphql/Descriptions - Types::ProjectType.connection_type, - null: false, + field :projects, Types::ProjectType.connection_type, null: false, + description: 'Projects within this namespace', resolver: ::Resolvers::NamespaceProjectsResolver end end diff --git a/app/graphql/types/project_statistics_type.rb b/app/graphql/types/project_statistics_type.rb index 5045471a75b..c46410df6c0 100644 --- a/app/graphql/types/project_statistics_type.rb +++ b/app/graphql/types/project_statistics_type.rb @@ -6,13 +6,20 @@ module Types authorize :read_statistics - field :commit_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :commit_count, GraphQL::INT_TYPE, null: false, + description: 'Commit count of the project' - field :storage_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :repository_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :lfs_objects_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :build_artifacts_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :packages_size, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :wiki_size, GraphQL::INT_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :storage_size, GraphQL::INT_TYPE, null: false, + description: 'Storage size of the project' + field :repository_size, GraphQL::INT_TYPE, null: false, + description: 'Repository size of the project' + field :lfs_objects_size, GraphQL::INT_TYPE, null: false, + description: 'Large File Storage (LFS) object size of the project' + field :build_artifacts_size, GraphQL::INT_TYPE, null: false, + description: 'Build artifacts size of the project' + field :packages_size, GraphQL::INT_TYPE, null: false, + description: 'Packages size of the project' + field :wiki_size, GraphQL::INT_TYPE, null: true, + description: 'Wiki size of the project' end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 5663f833b7a..73255021119 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -8,97 +8,142 @@ module Types expose_permissions Types::PermissionTypes::Project - field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the project' - field :full_path, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :full_path, GraphQL::ID_TYPE, null: false, + description: 'Full path of the project' + field :path, GraphQL::STRING_TYPE, null: false, + description: 'Path of the project' - field :name_with_namespace, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :name_with_namespace, GraphQL::STRING_TYPE, null: false, + description: 'Full name of the project with its namespace' + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Name of the project (without namespace)' - field :description, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions + field :description, GraphQL::STRING_TYPE, null: true, + description: 'Short description of the project' markdown_field :description_html, null: true - field :tag_list, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :http_url_to_repo, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :star_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :forks_count, GraphQL::INT_TYPE, null: false, calls_gitaly: true # 4 times # rubocop:disable Graphql/Descriptions - - field :created_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - field :last_activity_at, Types::TimeType, null: true # rubocop:disable Graphql/Descriptions - - field :archived, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :visibility, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.avatar_url(only_path: false) - end + field :tag_list, GraphQL::STRING_TYPE, null: true, + description: 'List of project tags' + + field :ssh_url_to_repo, GraphQL::STRING_TYPE, null: true, + description: 'URL to connect to the project via SSH' + field :http_url_to_repo, GraphQL::STRING_TYPE, null: true, + description: 'URL to connect to the project via HTTPS' + field :web_url, GraphQL::STRING_TYPE, null: true, + description: 'Web URL of the project' + + field :star_count, GraphQL::INT_TYPE, null: false, + description: 'Number of times the project has been starred' + field :forks_count, GraphQL::INT_TYPE, null: false, calls_gitaly: true, # 4 times + description: 'Number of times the project has been forked' + + field :created_at, Types::TimeType, null: true, + description: 'Timestamp of the project creation' + field :last_activity_at, Types::TimeType, null: true, + description: 'Timestamp of the project last activity' + + field :archived, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Archived status of the project' + + field :visibility, GraphQL::STRING_TYPE, null: true, + description: 'Visibility of the project' + + field :container_registry_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the project stores Docker container images in a container registry' + field :shared_runners_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if shared runners are enabled on the project' + field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if the project has Large File Storage (LFS) enabled' + field :merge_requests_ff_only_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if no merge commits should be created and all merges should instead be fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded.' + + field :avatar_url, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, + description: 'URL to avatar image file of the project', + resolve: -> (project, args, ctx) do + project.avatar_url(only_path: false) + end %i[issues merge_requests wiki snippets].each do |feature| - field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.feature_available?(feature, ctx[:current_user]) - end + field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, + description: "(deprecated) Does this project have #{feature} enabled?. Use `#{feature}_access_level` instead", + resolve: -> (project, args, ctx) do + project.feature_available?(feature, ctx[:current_user]) + end end - field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.feature_available?(:builds, ctx[:current_user]) - end - - field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true # rubocop:disable Graphql/Descriptions - - field :open_issues_count, GraphQL::INT_TYPE, null: true, resolve: -> (project, args, ctx) do # rubocop:disable Graphql/Descriptions - project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) - end - - field :import_status, GraphQL::STRING_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true # rubocop:disable Graphql/Descriptions - - field :namespace, Types::NamespaceType, null: true # rubocop:disable Graphql/Descriptions - field :group, Types::GroupType, null: true # rubocop:disable Graphql/Descriptions - - field :statistics, Types::ProjectStatisticsType, # rubocop:disable Graphql/Descriptions + field :jobs_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: '(deprecated) Enable jobs for this project. Use `builds_access_level` instead', + resolve: -> (project, args, ctx) do + project.feature_available?(:builds, ctx[:current_user]) + end + + field :public_jobs, GraphQL::BOOLEAN_TYPE, method: :public_builds, null: true, + description: 'Indicates if there is public access to pipelines and job details of the project, including output logs and artifacts' + + field :open_issues_count, GraphQL::INT_TYPE, null: true, + description: 'Number of open issues for the project', + resolve: -> (project, args, ctx) do + project.open_issues_count if project.feature_available?(:issues, ctx[:current_user]) + end + + field :import_status, GraphQL::STRING_TYPE, null: true, + description: 'Status of project import background job of the project' + + field :only_allow_merge_if_pipeline_succeeds, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if merge requests of the project can only be merged with successful jobs' + field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if users can request member access to the project' + field :only_allow_merge_if_all_discussions_are_resolved, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if merge requests of the project can only be merged when all the discussions are resolved' + field :printing_merge_request_link_enabled, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if a link to create or view a merge request should display after a push to Git repositories of the project from the command line' + field :remove_source_branch_after_merge, GraphQL::BOOLEAN_TYPE, null: true, + description: 'Indicates if `Delete source branch` option should be enabled by default for all new merge requests of the project' + + field :namespace, Types::NamespaceType, null: true, + description: 'Namespace of the project' + field :group, Types::GroupType, null: true, + description: 'Group of the project' + + field :statistics, Types::ProjectStatisticsType, null: true, + description: 'Statistics of the project', resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchProjectStatisticsLoader.new(obj.id).find } - field :repository, Types::RepositoryType, null: true # rubocop:disable Graphql/Descriptions + field :repository, Types::RepositoryType, null: true, + description: 'Git repository of the project' - field :merge_requests, # rubocop:disable Graphql/Descriptions + field :merge_requests, Types::MergeRequestType.connection_type, null: true, + description: 'Merge requests of the project', resolver: Resolvers::MergeRequestsResolver - field :merge_request, # rubocop:disable Graphql/Descriptions + field :merge_request, Types::MergeRequestType, null: true, + description: 'A single merge request of the project', resolver: Resolvers::MergeRequestsResolver.single - field :issues, # rubocop:disable Graphql/Descriptions + field :issues, Types::IssueType.connection_type, null: true, + description: 'Issues of the project', resolver: Resolvers::IssuesResolver - field :issue, # rubocop:disable Graphql/Descriptions - Types::ExtendedIssueType, + field :issue, + Types::IssueType, null: true, + description: 'A single issue of the project', resolver: Resolvers::IssuesResolver.single - field :pipelines, # rubocop:disable Graphql/Descriptions + field :pipelines, Types::Ci::PipelineType.connection_type, null: true, + description: 'Build pipelines of the project', resolver: Resolvers::ProjectPipelinesResolver end end diff --git a/app/graphql/types/repository_type.rb b/app/graphql/types/repository_type.rb index 9ecd336b41d..f0c25e13a26 100644 --- a/app/graphql/types/repository_type.rb +++ b/app/graphql/types/repository_type.rb @@ -6,9 +6,13 @@ module Types authorize :download_code - field :root_ref, GraphQL::STRING_TYPE, null: true, calls_gitaly: true # rubocop:disable Graphql/Descriptions - field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty?, calls_gitaly: true # rubocop:disable Graphql/Descriptions - field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists? # rubocop:disable Graphql/Descriptions - field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true # rubocop:disable Graphql/Descriptions + field :root_ref, GraphQL::STRING_TYPE, null: true, calls_gitaly: true, + description: 'Default branch of the repository' + field :empty, GraphQL::BOOLEAN_TYPE, null: false, method: :empty?, calls_gitaly: true, + description: 'Indicates repository has no visible content' + field :exists, GraphQL::BOOLEAN_TYPE, null: false, method: :exists?, + description: 'Indicates a corresponding Git repository exists on disk' + field :tree, Types::Tree::TreeType, null: true, resolver: Resolvers::TreeResolver, calls_gitaly: true, + description: 'Tree of the repository' end end diff --git a/app/graphql/types/task_completion_status.rb b/app/graphql/types/task_completion_status.rb index 0aa8fc60a7c..73a8b4f3020 100644 --- a/app/graphql/types/task_completion_status.rb +++ b/app/graphql/types/task_completion_status.rb @@ -8,8 +8,10 @@ module Types graphql_name 'TaskCompletionStatus' description 'Completion status of tasks' - field :count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :completed_count, GraphQL::INT_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :count, GraphQL::INT_TYPE, null: false, + description: 'Number of total tasks' + field :completed_count, GraphQL::INT_TYPE, null: false, + description: 'Number of completed tasks' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/todo_target_enum.rb b/app/graphql/types/todo_target_enum.rb index 9a7391dcd99..8358a86b35c 100644 --- a/app/graphql/types/todo_target_enum.rb +++ b/app/graphql/types/todo_target_enum.rb @@ -2,8 +2,10 @@ module Types class TodoTargetEnum < BaseEnum - value 'Issue' - value 'MergeRequest' - value 'Epic' + value 'COMMIT', value: 'Commit', description: 'A Commit' + value 'ISSUE', value: 'Issue', description: 'An Issue' + value 'MERGEREQUEST', value: 'MergeRequest', description: 'A MergeRequest' end end + +Types::TodoTargetEnum.prepend_if_ee('::EE::Types::TodoTargetEnum') diff --git a/app/graphql/types/todo_type.rb b/app/graphql/types/todo_type.rb index d36daaf7dec..5ce5093c55e 100644 --- a/app/graphql/types/todo_type.rb +++ b/app/graphql/types/todo_type.rb @@ -40,7 +40,8 @@ module Types field :body, GraphQL::STRING_TYPE, description: 'Body of the todo', - null: false + null: false, + calls_gitaly: true # TODO This is only true when `target_type` is `Commit`. See https://gitlab.com/gitlab-org/gitlab/issues/34757#note_234752665 field :state, Types::TodoStateEnum, description: 'State of the todo', diff --git a/app/graphql/types/tree/entry_type.rb b/app/graphql/types/tree/entry_type.rb index 10c2ad8815e..87a3eced896 100644 --- a/app/graphql/types/tree/entry_type.rb +++ b/app/graphql/types/tree/entry_type.rb @@ -5,6 +5,7 @@ module Types include Types::BaseInterface field :id, GraphQL::ID_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :sha, GraphQL::STRING_TYPE, null: false, description: "Last commit sha for entry", method: :id field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions field :type, Tree::TypeEnum, null: false # rubocop:disable Graphql/Descriptions field :path, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index 1ba37927b40..b45c7893e75 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -8,12 +8,16 @@ module Types present_using UserPresenter - field :name, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :username, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :avatar_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions - field :web_url, GraphQL::STRING_TYPE, null: false # rubocop:disable Graphql/Descriptions + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Human-readable name of the user' + field :username, GraphQL::STRING_TYPE, null: false, + description: 'Username of the user. Unique within this instance of GitLab' + field :avatar_url, GraphQL::STRING_TYPE, null: false, + description: "URL of the user's avatar" + field :web_url, GraphQL::STRING_TYPE, null: false, + description: 'Web URL of the user' field :todos, Types::TodoType.connection_type, null: false, resolver: Resolvers::TodoResolver, - description: 'Todos of this user' + description: 'Todos of the user' end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index ecaeb7060c8..3ae804ff231 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -324,6 +324,15 @@ module ApplicationHelper } end + def asset_to_string(name) + app = Rails.application + if Rails.configuration.assets.compile + app.assets.find_asset(name).to_s + else + controller.view_context.render(file: Rails.root.join('public/assets', app.assets_manifest.assets[name]).to_s) + end + end + private def appearance diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index df17b82412f..a011209375e 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -3,11 +3,11 @@ module ApplicationSettingsHelper extend self - delegate :allow_signup?, - :gravatar_enabled?, - :password_authentication_enabled_for_web?, - :akismet_enabled?, - to: :'Gitlab::CurrentSettings.current_application_settings' + delegate :allow_signup?, + :gravatar_enabled?, + :password_authentication_enabled_for_web?, + :akismet_enabled?, + to: :'Gitlab::CurrentSettings.current_application_settings' def user_oauth_applications? Gitlab::CurrentSettings.user_oauth_applications @@ -176,6 +176,7 @@ module ApplicationSettingsHelper :container_registry_token_expire_delay, :default_artifacts_expire_in, :default_branch_protection, + :default_ci_config_path, :default_group_visibility, :default_project_creation, :default_project_visibility, @@ -193,6 +194,10 @@ module ApplicationSettingsHelper :dsa_key_restriction, :ecdsa_key_restriction, :ed25519_key_restriction, + :eks_integration_enabled, + :eks_account_id, + :eks_access_key_id, + :eks_secret_access_key, :email_author_in_body, :enabled_git_access_protocol, :enforce_terms, @@ -254,6 +259,9 @@ module ApplicationSettingsHelper :shared_runners_text, :sign_in_text, :signup_enabled, + :sourcegraph_enabled, + :sourcegraph_url, + :sourcegraph_public_only, :terminal_max_session_time, :terms, :throttle_authenticated_api_enabled, @@ -289,7 +297,8 @@ module ApplicationSettingsHelper :snowplow_collector_hostname, :snowplow_cookie_domain, :snowplow_enabled, - :snowplow_site_id, + :snowplow_app_id, + :snowplow_iglu_registry_url, :push_event_hooks_limit, :push_event_activities_limit, :custom_http_clone_url_root @@ -312,6 +321,10 @@ module ApplicationSettingsHelper Rails.env.test? end + def integration_expanded?(substring) + @application_setting.errors.any? { |k| k.to_s.start_with?(substring) } + end + def instance_clusters_enabled? can?(current_user, :read_cluster, Clusters::Instance.new) end diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index 9e6fcf6a267..a9c4cfe7dcc 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -8,6 +8,10 @@ module AuthHelper Gitlab::Auth::LDAP::Config.enabled? end + def ldap_sign_in_enabled? + Gitlab::Auth::LDAP::Config.sign_in_enabled? + end + def omniauth_enabled? Gitlab::Auth.omniauth_enabled? end @@ -56,6 +60,16 @@ module AuthHelper auth_providers.select { |provider| form_based_provider?(provider) } end + def any_form_based_providers_enabled? + form_based_providers.any? { |provider| form_enabled_for_sign_in?(provider) } + end + + def form_enabled_for_sign_in?(provider) + return true unless provider.to_s.match?(LDAP_PROVIDER) + + ldap_sign_in_enabled? + end + def crowd_enabled? auth_providers.include? :crowd end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 5c24b0e1704..912f0b61978 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -47,7 +47,7 @@ module BlobHelper def edit_blob_button(project = @project, ref = @ref, path = @path, options = {}) return unless blob = readable_blob(options, path, project, ref) - common_classes = "btn js-edit-blob #{options[:extra_class]}" + common_classes = "btn btn-primary js-edit-blob #{options[:extra_class]}" edit_button_tag(blob, common_classes, @@ -62,7 +62,7 @@ module BlobHelper return unless blob = readable_blob(options, path, project, ref) edit_button_tag(blob, - 'btn btn-default', + 'btn btn-inverted btn-primary ide-edit-button', _('Web IDE'), ide_edit_path(project, ref, path, options), project, @@ -108,7 +108,7 @@ module BlobHelper path, label: _("Delete"), action: "delete", - btn_class: "remove", + btn_class: "default", modal_type: "remove" ) end @@ -141,11 +141,7 @@ module BlobHelper if @build && @entry raw_project_job_artifacts_url(@project, @build, path: @entry.path, **kwargs) elsif @snippet - if @snippet.project_id - raw_project_snippet_url(@project, @snippet, **kwargs) - else - raw_snippet_url(@snippet, **kwargs) - end + reliable_raw_snippet_url(@snippet) elsif @blob project_raw_url(@project, @id, **kwargs) end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index a5fe6bb8f07..2def3488184 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -4,12 +4,12 @@ module BuildsHelper def build_summary(build, skip: false) if build.has_trace? if skip - link_to _("View job trace"), pipeline_job_url(build.pipeline, build) + link_to _("View job log"), pipeline_job_url(build.pipeline, build) else build.trace.html(last_lines: 10).html_safe end else - _("No job trace") + _("No job log") end end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 7ca509873cc..0037c49f134 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -6,6 +6,28 @@ module ClustersHelper false end + def create_new_cluster_label(provider: nil) + case provider + when 'aws' + s_('ClusterIntegration|Create new Cluster on EKS') + when 'gcp' + s_('ClusterIntegration|Create new Cluster on GKE') + else + s_('ClusterIntegration|Create new Cluster') + end + end + + def new_cluster_partial(provider: nil) + case provider + when 'aws' + 'clusters/clusters/aws/new' + when 'gcp' + 'clusters/clusters/gcp/new' + else + 'clusters/clusters/cloud_providers/cloud_provider_selector' + end + end + def render_gcp_signup_offer return if Gitlab::CurrentSettings.current_application_settings.hide_third_party_offers? return unless show_gcp_signup_offer? @@ -18,7 +40,7 @@ module ClustersHelper def has_rbac_enabled?(cluster) return cluster.platform_kubernetes_rbac? if cluster.platform_kubernetes - !cluster.provider.legacy_abac? + cluster.provider.has_rbac_enabled? end end diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 518cb7c9714..679622897aa 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -27,16 +27,25 @@ module DashboardHelper false end - def feature_entry(title, href: nil, enabled: true) + def feature_entry(title, href: nil, enabled: true, doc_href: nil) enabled_text = enabled ? 'on' : 'off' label = "#{title}: status #{enabled_text}" link_or_title = href && enabled ? tag.a(title, href: href) : title tag.p(aria: { label: label }) do concat(link_or_title) + concat(tag.span(class: ['light', 'float-right']) do - concat(boolean_to_icon(enabled)) + boolean_to_icon(enabled) end) + + if doc_href.present? + link_to_doc = link_to(sprite_icon('question', size: 16), doc_href, + class: 'prepend-left-5', title: _('Documentation'), + target: '_blank', rel: 'noopener noreferrer') + + concat(link_to_doc) + end end end diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index c642a64ad61..f57d0fa19d4 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -34,6 +34,7 @@ module EnvironmentsHelper "project-path" => project_path(project), "tags-path" => project_tags_path(project), "has-metrics" => "#{environment.has_metrics?}", + "prometheus-status" => "#{environment.prometheus_status}", "external-dashboard-url" => project.metrics_setting_external_dashboard_url } end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 4f31cc67ccc..404ea7b00d4 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -66,7 +66,7 @@ module GitlabRoutingHelper end def preview_markdown_path(parent, *args) - return group_preview_markdown_path(parent) if parent.is_a?(Group) + return group_preview_markdown_path(parent, *args) if parent.is_a?(Group) if @snippet.is_a?(PersonalSnippet) preview_markdown_snippets_path diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index df9d1933271..3c72f41a4c9 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -281,10 +281,7 @@ module IssuablesHelper } data[:hasClosingMergeRequest] = issuable.merge_requests_count(current_user) != 0 if issuable.is_a?(Issue) - - zoom_links = Gitlab::ZoomLinkExtractor.new(issuable.description).links - - data[:zoomMeetingUrl] = zoom_links.last if zoom_links.any? + data[:zoomMeetingUrl] = ZoomMeeting.canonical_meeting_url(issuable) if issuable.is_a?(Issue) if parent.is_a?(Group) data[:groupPath] = parent.path diff --git a/app/helpers/markup_helper.rb b/app/helpers/markup_helper.rb index e2524938e10..e1e756c2f4c 100644 --- a/app/helpers/markup_helper.rb +++ b/app/helpers/markup_helper.rb @@ -51,12 +51,15 @@ module MarkupHelper text = fragment.children[0].text fragment.children[0].replace(link_to(text, url, html_options)) else - # Traverse the fragment's first generation of children looking for pure - # text, wrapping anything found in the requested link + # Traverse the fragment's first generation of children looking for + # either pure text or emojis, wrapping anything found in the + # requested link fragment.children.each do |node| - next unless node.text? - - node.replace(link_to(node.text, url, html_options)) + if node.text? + node.replace(link_to(node.text, url, html_options)) + elsif node.name == 'gl-emoji' + node.replace(link_to(node.to_html.html_safe, url, html_options)) + end end end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index e769734f27b..b12b39073ef 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -4,6 +4,18 @@ module MilestonesHelper include EntityDateHelper include Gitlab::Utils::StrongMemoize + def milestone_status_string(milestone) + if milestone.closed? + _('Closed') + elsif milestone.expired? + _('Past due') + elsif milestone.upcoming? + _('Upcoming') + else + _('Open') + end + end + def milestones_filter_path(opts = {}) if @project project_milestones_path(@project, opts) @@ -170,6 +182,23 @@ module MilestonesHelper content.join('<br />').html_safe end + def milestone_releases_tooltip_text(milestone) + count = milestone.releases.count + + return _("Releases") if count.zero? + + n_("%{releases} release", "%{releases} releases", count) % { releases: count } + end + + def recent_releases_with_counts(milestone) + total_count = milestone.releases.size + return [[], 0, 0] if total_count == 0 + + recent_releases = milestone.releases.recent.to_a + more_count = total_count - recent_releases.size + [recent_releases, total_count, more_count] + end + def milestone_tooltip_due_date(milestone) if milestone.due_date "#{milestone.due_date.to_s(:medium)} (#{remaining_days_in_words(milestone.due_date, milestone.start_date)})" @@ -196,33 +225,19 @@ module MilestonesHelper end end - def milestone_merge_request_tab_path(milestone) - if @project - merge_requests_project_milestone_path(@project, milestone, format: :json) - elsif @group - merge_requests_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) - else - merge_requests_dashboard_milestone_path(milestone, title: milestone.title, format: :json) - end - end - - def milestone_participants_tab_path(milestone) - if @project - participants_project_milestone_path(@project, milestone, format: :json) - elsif @group - participants_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + def milestone_tab_path(milestone, tab) + if milestone.global_milestone? + url_for(action: tab, title: milestone.title, format: :json) else - participants_dashboard_milestone_path(milestone, title: milestone.title, format: :json) + url_for(action: tab, format: :json) end end - def milestone_labels_tab_path(milestone) - if @project - labels_project_milestone_path(@project, milestone, format: :json) - elsif @group - labels_group_milestone_path(@group, milestone.safe_title, title: milestone.title, format: :json) + def update_milestone_path(milestone, params = {}) + if milestone.project_milestone? + project_milestone_path(milestone.project, milestone, milestone: params) else - labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json) + group_milestone_route(milestone, params) end end @@ -247,6 +262,14 @@ module MilestonesHelper milestone_path(milestone.milestone, params) end + def edit_milestone_path(milestone) + if milestone.group_milestone? + edit_group_milestone_path(milestone.group, milestone) + elsif milestone.project_milestone? + edit_project_milestone_path(milestone.project, milestone) + end + end + def can_admin_project_milestones? strong_memoize(:can_admin_project_milestones) do can?(current_user, :admin_milestone, @project) diff --git a/app/helpers/projects/error_tracking_helper.rb b/app/helpers/projects/error_tracking_helper.rb index fd1222a1dfb..2f5f612ed4c 100644 --- a/app/helpers/projects/error_tracking_helper.rb +++ b/app/helpers/projects/error_tracking_helper.rb @@ -13,4 +13,13 @@ module Projects::ErrorTrackingHelper 'illustration-path' => image_path('illustrations/cluster_popover.svg') } end + + def error_details_data(project, issue) + opts = [project, issue, { format: :json }] + + { + 'issue-details-path' => details_namespace_project_error_tracking_index_path(*opts), + 'issue-stack-trace-path' => stack_trace_namespace_project_error_tracking_index_path(*opts) + } + end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 16360c7139a..47214ac4ee2 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -362,6 +362,10 @@ module ProjectsHelper @project.grafana_integration&.token end + def grafana_integration_enabled? + @project.grafana_integration&.enabled? + end + private def get_project_nav_tabs(project, current_user) diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 68a19152d8f..c4fe40a0875 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -26,7 +26,8 @@ module ReleasesHelper tag_name: @release.tag, markdown_preview_path: preview_markdown_path(@project), markdown_docs_path: help_page_path('user/markdown'), - releases_page_path: project_releases_path(@project, anchor: @release.tag) + releases_page_path: project_releases_path(@project, anchor: @release.tag), + update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release') } end end diff --git a/app/helpers/repository_languages_helper.rb b/app/helpers/repository_languages_helper.rb index cf7eee7fff3..7834e86adab 100644 --- a/app/helpers/repository_languages_helper.rb +++ b/app/helpers/repository_languages_helper.rb @@ -4,7 +4,7 @@ module RepositoryLanguagesHelper def repository_languages_bar(languages) return if languages.none? - content_tag :div, class: 'progress repository-languages-bar' do + content_tag :div, class: 'progress repository-languages-bar js-show-on-project-root' do safe_join(languages.map { |lang| language_progress(lang) }) end end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 9a19758b4e8..777fe82e4c0 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true module SearchHelper + SEARCH_PERMITTED_PARAMS = [:search, :scope, :project_id, :group_id, :repository_ref, :snippets].freeze + def search_autocomplete_opts(term) return unless current_user @@ -96,8 +98,9 @@ module SearchHelper result end - def search_blob_title(project, filename) - filename + # Overriden in EE + def search_blob_title(project, path) + path end def search_service @@ -199,7 +202,7 @@ module SearchHelper search_params = params .merge(search) .merge({ scope: scope }) - .permit(:search, :scope, :project_id, :group_id, :repository_ref, :snippets) + .permit(SEARCH_PERMITTED_PARAMS) if @scope == scope li_class = 'active' @@ -235,6 +238,7 @@ module SearchHelper opts[:data]['project-id'] = @project.id opts[:data]['labels-endpoint'] = project_labels_path(@project) opts[:data]['milestones-endpoint'] = project_milestones_path(@project) + opts[:data]['releases-endpoint'] = project_releases_path(@project) elsif @group.present? opts[:data]['group-id'] = @group.id opts[:data]['labels-endpoint'] = group_labels_path(@group) @@ -272,7 +276,7 @@ module SearchHelper sanitize(html, tags: %w(a p ol ul li pre code)) end - def search_tabs?(tab) + def show_user_search_tab? return false if Feature.disabled?(:users_search, default_enabled: true) if @project diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index ea7c7af72d3..19a27ba3499 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -32,7 +32,7 @@ module ServicesHelper end def service_save_button(service) - button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?) do + button_tag(class: 'btn btn-success', type: 'submit', disabled: service.deprecated?, data: { qa_selector: 'save_changes_button' }) do icon('spinner spin', class: 'hidden js-btn-spinner') + content_tag(:span, 'Save changes', class: 'js-btn-label') end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index af98a611b8b..ef737b25bc7 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -4,4 +4,20 @@ module SessionsHelper def unconfirmed_email? flash[:alert] == t(:unconfirmed, scope: [:devise, :failure]) end + + # By default, all sessions are given the same expiration time configured in + # the session store (e.g. 1 week). However, unauthenticated users can + # generate a lot of sessions, primarily for CSRF verification. It makes + # sense to reduce the TTL for unauthenticated to something much lower than + # the default (e.g. 1 hour) to limit Redis memory. In addition, Rails + # creates a new session after login, so the short TTL doesn't even need to + # be extended. + def limit_session_time + # Rack sets this header, but not all tests may have it: https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L251-L259 + return unless request.env['rack.session.options'] + + # This works because Rack uses these options every time a request is handled: + # https://github.com/rack/rack/blob/fdcd03a3c5a1c51d1f96fc97f9dfa1a9deac0c77/lib/rack/session/abstract/id.rb#L342 + request.env['rack.session.options'][:expire_after] = Settings.gitlab['unauthenticated_session_expire_delay'] + end end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index 6ccc1fb2ed1..10e31fb8888 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -11,22 +11,40 @@ module SnippetsHelper end end - def reliable_snippet_path(snippet, opts = nil) + def reliable_snippet_path(snippet, opts = {}) + reliable_snippet_url(snippet, opts.merge(only_path: true)) + end + + def reliable_raw_snippet_path(snippet, opts = {}) + reliable_raw_snippet_url(snippet, opts.merge(only_path: true)) + end + + def reliable_snippet_url(snippet, opts = {}) if snippet.project_id? - project_snippet_path(snippet.project, snippet, opts) + project_snippet_url(snippet.project, snippet, nil, opts) else - snippet_path(snippet, opts) + snippet_url(snippet, nil, opts) end end - def download_snippet_path(snippet) - if snippet.project_id - raw_project_snippet_path(@project, snippet, inline: false) + def reliable_raw_snippet_url(snippet, opts = {}) + if snippet.project_id? + raw_project_snippet_url(snippet.project, snippet, nil, opts) else - raw_snippet_path(snippet, inline: false) + raw_snippet_url(snippet, nil, opts) end end + def download_raw_snippet_button(snippet) + link_to(icon('download'), + reliable_raw_snippet_path(snippet, inline: false), + target: '_blank', + rel: 'noopener noreferrer', + class: "btn btn-sm has-tooltip", + title: 'Download', + data: { container: 'body' }) + end + # Return the path of a snippets index for a user or for a project # # @returns String, path to snippet index @@ -114,30 +132,45 @@ module SnippetsHelper { snippet_object: snippet, snippet_chunks: snippet_chunks } end - def snippet_embed - "<script src=\"#{url_for(only_path: false, overwrite_params: nil)}.js\"></script>" + def snippet_embed_tag(snippet) + content_tag(:script, nil, src: reliable_snippet_url(snippet, format: :js, only_path: false)) + end + + def snippet_badge(snippet) + return unless attrs = snippet_badge_attributes(snippet) + + css_class, text = attrs + tag.span(class: ['badge', 'badge-gray']) do + concat(tag.i(class: ['fa', css_class])) + concat(' ') + concat(text) + end + end + + def snippet_badge_attributes(snippet) + if snippet.private? + ['fa-lock', _('private')] + end end - def embedded_snippet_raw_button + def embedded_raw_snippet_button blob = @snippet.blob return if blob.empty? || blob.binary? || blob.stored_externally? - snippet_raw_url = if @snippet.is_a?(PersonalSnippet) - raw_snippet_url(@snippet) - else - raw_project_snippet_url(@snippet.project, @snippet) - end - - link_to external_snippet_icon('doc-code'), snippet_raw_url, class: 'btn', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw' + link_to(external_snippet_icon('doc-code'), + reliable_raw_snippet_url(@snippet), + class: 'btn', + target: '_blank', + rel: 'noopener noreferrer', + title: 'Open raw') end def embedded_snippet_download_button - download_url = if @snippet.is_a?(PersonalSnippet) - raw_snippet_url(@snippet, inline: false) - else - raw_project_snippet_url(@snippet.project, @snippet, inline: false) - end - - link_to external_snippet_icon('download'), download_url, class: 'btn', target: '_blank', title: 'Download', rel: 'noopener noreferrer' + link_to(external_snippet_icon('download'), + reliable_raw_snippet_url(@snippet, inline: false), + class: 'btn', + target: '_blank', + title: 'Download', + rel: 'noopener noreferrer') end end diff --git a/app/helpers/sourcegraph_helper.rb b/app/helpers/sourcegraph_helper.rb new file mode 100644 index 00000000000..cc5a5c77e9a --- /dev/null +++ b/app/helpers/sourcegraph_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module SourcegraphHelper + def sourcegraph_url_message + link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: Gitlab::CurrentSettings.sourcegraph_url } + link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe + + message = + if Gitlab::CurrentSettings.sourcegraph_url_is_com? + s_('SourcegraphPreferences|Uses %{link_start}Sourcegraph.com%{link_end}.').html_safe + else + s_('SourcegraphPreferences|Uses a custom %{link_start}Sourcegraph instance%{link_end}.').html_safe + end + + message % { link_start: link_start, link_end: link_end } + end + + def sourcegraph_experimental_message + if Gitlab::Sourcegraph.feature_conditional? + s_("SourcegraphPreferences|This feature is experimental and currently limited to certain projects.") + elsif Gitlab::CurrentSettings.sourcegraph_public_only + s_("SourcegraphPreferences|This feature is experimental and limited to public projects.") + else + s_("SourcegraphPreferences|This feature is experimental.") + end + end +end diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 53739cb63e3..58edb327be0 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -108,16 +108,6 @@ module TabHelper current_controller?(c) && current_action?(a) end - def project_tab_class - if controller.controller_path.start_with?('projects') - return 'active' - end - - if %w(services hooks deploy_keys protected_branches).include? controller.controller_name - "active" - end - end - def branches_tab_class if current_controller?(:protected_branches) || current_controller?(:branches) || @@ -125,14 +115,6 @@ module TabHelper 'active' end end - - def profile_tab_class - if controller.controller_path.start_with?('profiles') - return 'active' - end - - 'active' if current_controller?('oauth/applications') - end end TabHelper.prepend_if_ee('EE::TabHelper') diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index afa057421e0..fc25b78da93 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -186,6 +186,24 @@ module TreeHelper attrs end + + def vue_file_list_data(project, ref) + { + project_path: project.full_path, + project_short_path: project.path, + ref: ref, + full_name: project.name_with_namespace + } + end + + def directory_download_links(project, ref, archive_prefix) + Gitlab::Workhorse::ARCHIVE_FORMATS.map do |fmt| + { + text: fmt, + path: project_archive_path(project, id: tree_join(ref, archive_prefix), format: fmt) + } + end + end end TreeHelper.prepend_if_ee('::EE::TreeHelper') diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index 4ff25d021fb..ef0cb8b4bcb 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -95,6 +95,14 @@ module UsersHelper tabs end + def trials_link_url + 'https://about.gitlab.com/free-trial/' + end + + def trials_allowed?(user) + false + end + def get_current_user_menu_items items = [] @@ -105,6 +113,7 @@ module UsersHelper items << :help items << :profile if can?(current_user, :read_user, current_user) items << :settings if can?(current_user, :update_user, current_user) + items << :start_trial if trials_allowed?(current_user) items end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 2bd803c0177..a36de5dc548 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -201,9 +201,9 @@ module VisibilityLevelHelper def visibility_level_errors_for_group(group, level_name) group_name = link_to group.name, group_path(group) - change_visiblity = link_to 'change the visibility', edit_group_path(group) + change_visibility = link_to 'change the visibility', edit_group_path(group) { reason: "the visibility of #{group_name} is #{group.visibility}", - instruction: " To make this group #{level_name}, you must first #{change_visiblity} of the parent group." } + instruction: " To make this group #{level_name}, you must first #{change_visibility} of the parent group." } end end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index ea8032324aa..06d2219d6a9 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -15,16 +15,18 @@ module Emails user = User.find(recipient_id) - mail(to: user.notification_email_for(notification_group), - subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) + member_email_with_layout( + to: user.notification_email_for(notification_group), + subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) end def member_access_granted_email(member_source_type, member_id) @member_source_type = member_source_type @member_id = member_id - mail(to: member.user.notification_email_for(notification_group), - subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) + member_email_with_layout( + to: member.user.notification_email_for(notification_group), + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) end def member_access_denied_email(member_source_type, source_id, user_id) @@ -33,8 +35,9 @@ module Emails user = User.find(user_id) - mail(to: user.notification_email_for(notification_group), - subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) + member_email_with_layout( + to: user.notification_email_for(notification_group), + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) end def member_invited_email(member_source_type, member_id, token) @@ -42,8 +45,9 @@ module Emails @member_id = member_id @token = token - mail(to: member.invite_email, - subject: subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")) + member_email_with_layout( + to: member.invite_email, + subject: subject("Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}")) end def member_invite_accepted_email(member_source_type, member_id) @@ -51,8 +55,9 @@ module Emails @member_id = member_id return unless member.created_by - mail(to: member.created_by.notification_email_for(notification_group), - subject: subject('Invitation accepted')) + member_email_with_layout( + to: member.created_by.notification_email_for(notification_group), + subject: subject('Invitation accepted')) end def member_invite_declined_email(member_source_type, source_id, invite_email, created_by_id) @@ -64,8 +69,9 @@ module Emails user = User.find(created_by_id) - mail(to: user.notification_email_for(notification_group), - subject: subject('Invitation declined')) + member_email_with_layout( + to: user.notification_email_for(notification_group), + subject: subject('Invitation declined')) end def member @@ -85,5 +91,12 @@ module Emails def member_source_class @member_source_type.classify.constantize end + + def member_email_with_layout(to:, subject:) + mail(to: to, subject: subject) do |format| + format.html { render layout: 'mailer' } + format.text { render layout: 'mailer' } + end + end end end diff --git a/app/mailers/emails/pipelines.rb b/app/mailers/emails/pipelines.rb index 34e12a5fa6d..95bb52d8f97 100644 --- a/app/mailers/emails/pipelines.rb +++ b/app/mailers/emails/pipelines.rb @@ -18,12 +18,11 @@ module Emails @merge_request = pipeline.all_merge_requests.first add_headers - # We use bcc here because we don't want to generate this emails for a + # We use bcc here because we don't want to generate these emails for a # thousand times. This could be potentially expensive in a loop, and # recipients would contain all project watchers so it could be a lot. mail(bcc: recipients, - subject: pipeline_subject(status), - skip_premailer: true) do |format| + subject: pipeline_subject(status)) do |format| format.html { render layout: 'mailer' } format.text { render layout: 'mailer' } end diff --git a/app/mailers/emails/releases.rb b/app/mailers/emails/releases.rb index 137858d31e8..c9c77ab9333 100644 --- a/app/mailers/emails/releases.rb +++ b/app/mailers/emails/releases.rb @@ -21,7 +21,13 @@ module Emails private def release_email_subject - release_info = [@release.name, @release.tag].select(&:presence).join(' - ') + release_info = + if @release.name == @release.tag + @release.tag + else + [@release.name, @release.tag].select(&:presence).join(' - ') + end + "New release: #{release_info}" end end diff --git a/app/mailers/previews/notify_preview.rb b/app/mailers/previews/notify_preview.rb index 3d42423ba46..381a4f54d9e 100644 --- a/app/mailers/previews/notify_preview.rb +++ b/app/mailers/previews/notify_preview.rb @@ -77,7 +77,7 @@ class NotifyPreview < ActionMailer::Preview end def import_issues_csv_email - Notify.import_issues_csv_email(user, project, { success: 3, errors: [5, 6, 7], valid_file: true }) + Notify.import_issues_csv_email(user.id, project.id, { success: 3, errors: [5, 6, 7], valid_file: true }) end def closed_merge_request_email @@ -109,11 +109,11 @@ class NotifyPreview < ActionMailer::Preview end def member_access_requested_email - Notify.member_access_requested_email('group', user.id, user.id).message + Notify.member_access_requested_email(member.source_type, member.id, user.id).message end def member_invite_accepted_email - Notify.member_invite_accepted_email('project', user.id).message + Notify.member_invite_accepted_email(member.source_type, member.id).message end def member_invite_declined_email diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index a3a1748142f..7cfebf0473f 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -2,6 +2,7 @@ class AbuseReport < ApplicationRecord include CacheMarkdownField + include Sortable cache_markdown_field :message, pipeline: :single_line @@ -13,6 +14,9 @@ class AbuseReport < ApplicationRecord validates :message, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } + scope :by_user, -> (user) { where(user_id: user) } + scope :with_users, -> { includes(:reporter, :user) } + # For CacheMarkdownField alias_method :author, :reporter diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb index 23f0db0829b..b2c16444a2a 100644 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -10,6 +10,25 @@ module Analytics alias_attribute :parent, :project alias_attribute :parent_id, :project_id + + delegate :group, to: :project + + validate :validate_project_group_for_label_events, if: -> { start_event_label_based? || end_event_label_based? } + + def self.relative_positioning_query_base(stage) + where(project_id: stage.project_id) + end + + def self.relative_positioning_parent_column + :project_id + end + + private + + # Project should belong to a group when the stage has Label based events since only GroupLabels are allowed. + def validate_project_group_for_label_events + errors.add(:project, s_('CycleAnalyticsStage|should be under a group')) unless project.group + end end end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index a07933d4975..4028d711fd1 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -6,6 +6,12 @@ class ApplicationSetting < ApplicationRecord include TokenAuthenticatable include ChronicDurationAttribute + # Only remove this >= %12.6 and >= 2019-12-01 + self.ignored_columns += %i[ + pendo_enabled + pendo_url + ] + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token @@ -18,12 +24,6 @@ class ApplicationSetting < ApplicationRecord # fix a lot of tests using allow_any_instance_of include ApplicationSettingImplementation - attr_encrypted :asset_proxy_secret_key, - mode: :per_attribute_iv, - insecure_mode: true, - key: Settings.attr_encrypted_db_key_base_truncated, - algorithm: 'aes-256-cbc' - serialize :restricted_visibility_levels # rubocop:disable Cop/ActiveRecordSerialize serialize :import_sources # rubocop:disable Cop/ActiveRecordSerialize serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize @@ -99,11 +99,20 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :plantuml_enabled + validates :sourcegraph_url, + presence: true, + if: :sourcegraph_enabled + validates :snowplow_collector_hostname, presence: true, hostname: true, if: :snowplow_enabled + validates :snowplow_iglu_registry_url, + addressable_url: true, + allow_blank: true, + if: :snowplow_enabled + validates :max_attachment_size, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -270,12 +279,40 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :lets_encrypt_terms_of_service_accepted? + validates :eks_integration_enabled, + inclusion: { in: [true, false] } + + validates :eks_account_id, + format: { with: Gitlab::Regex.aws_account_id_regex, + message: Gitlab::Regex.aws_account_id_message }, + if: :eks_integration_enabled? + + validates :eks_access_key_id, + length: { in: 16..128 }, + if: :eks_integration_enabled? + + validates :eks_secret_access_key, + presence: true, + if: :eks_integration_enabled? + validates_with X509CertificateCredentialsValidator, certificate: :external_auth_client_cert, pkey: :external_auth_client_key, pass: :external_auth_client_key_pass, if: -> (setting) { setting.external_auth_client_cert.present? } + validates :default_ci_config_path, + format: { without: %r{(\.{2}|\A/)}, + message: N_('cannot include leading slash or directory traversal.') }, + length: { maximum: 255 }, + allow_blank: true + + attr_encrypted :asset_proxy_secret_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-cbc', + insecure_mode: true + attr_encrypted :external_auth_client_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, @@ -294,6 +331,12 @@ class ApplicationSetting < ApplicationRecord algorithm: 'aes-256-gcm', encode: true + attr_encrypted :eks_secret_access_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + before_validation :ensure_uuid! before_save :ensure_runners_registration_token @@ -304,6 +347,10 @@ class ApplicationSetting < ApplicationRecord end after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') } + def sourcegraph_url_is_com? + !!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/) + end + def self.create_from_defaults transaction(requires_new: true) do super diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 0c0ffb67c9a..7bb89f0d1e2 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -42,6 +42,7 @@ module ApplicationSettingImplementation container_registry_token_expire_delay: 5, default_artifacts_expire_in: '30 days', default_branch_protection: Settings.gitlab['default_branch_protection'], + default_ci_config_path: nil, default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_project_creation: Settings.gitlab['default_project_creation'], default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], @@ -54,6 +55,10 @@ module ApplicationSettingImplementation dsa_key_restriction: 0, ecdsa_key_restriction: 0, ed25519_key_restriction: 0, + eks_integration_enabled: false, + eks_account_id: nil, + eks_access_key_id: nil, + eks_secret_access_key: nil, first_day_of_week: 0, gitaly_timeout_default: 55, gitaly_timeout_fast: 10, @@ -97,6 +102,9 @@ module ApplicationSettingImplementation shared_runners_text: nil, sign_in_text: nil, signup_enabled: Settings.gitlab['signup_enabled'], + sourcegraph_enabled: false, + sourcegraph_url: nil, + sourcegraph_public_only: true, terminal_max_session_time: 0, throttle_authenticated_api_enabled: false, throttle_authenticated_api_period_in_seconds: 3600, @@ -128,8 +136,10 @@ module ApplicationSettingImplementation snowplow_collector_hostname: nil, snowplow_cookie_domain: nil, snowplow_enabled: false, - snowplow_site_id: nil, - custom_http_clone_url_root: nil + snowplow_app_id: nil, + snowplow_iglu_registry_url: nil, + custom_http_clone_url_root: nil, + productivity_analytics_start_date: Time.now } end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index 24fcb97db6e..5a33a8f89df 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -6,11 +6,14 @@ class AwardEmoji < ApplicationRecord include Participable include GhostUser + include Importable belongs_to :awardable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations belongs_to :user - validates :awardable, :user, presence: true + validates :user, presence: true + validates :awardable, presence: true, unless: :importing? + validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names } validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user? diff --git a/app/models/aws/role.rb b/app/models/aws/role.rb index 836107435ad..54132be749d 100644 --- a/app/models/aws/role.rb +++ b/app/models/aws/role.rb @@ -13,5 +13,11 @@ module Aws with: Gitlab::Regex.aws_arn_regex, message: Gitlab::Regex.aws_arn_regex_message } + + before_validation :ensure_role_external_id!, on: :create + + def ensure_role_external_id! + self.role_external_id ||= SecureRandom.hex(20) + end end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index c48ab28ce73..59a2c09bd28 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -35,10 +35,13 @@ module Ci refspecs: -> (build) { build.merge_request_ref? } }.freeze + DEFAULT_RETRIES = { + scheduler_failure: 2 + }.freeze + has_one :deployment, as: :deployable, class_name: 'Deployment' has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id - has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build has_many :job_artifacts, class_name: 'Ci::JobArtifact', foreign_key: :job_id, dependent: :destroy, inverse_of: :job # rubocop:disable Cop/ActiveRecordDependent has_many :job_variables, class_name: 'Ci::JobVariable', foreign_key: :job_id @@ -52,7 +55,6 @@ module Ci accepts_nested_attributes_for :runner_session accepts_nested_attributes_for :job_variables - accepts_nested_attributes_for :needs delegate :url, to: :runner_session, prefix: true, allow_nil: true delegate :terminal_specification, to: :runner_session, allow_nil: true @@ -118,6 +120,11 @@ module Ci scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } + scope :with_exposed_artifacts, -> do + joins(:metadata).merge(Ci::BuildMetadata.with_exposed_artifacts) + .includes(:metadata, :job_artifacts_metadata) + end + scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) } scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) } scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) } @@ -367,18 +374,25 @@ module Ci pipeline.builds.retried.where(name: self.name).count end - def retries_max - normalized_retry.fetch(:max, 0) + def retry_failure? + max_allowed_retries = nil + max_allowed_retries ||= options_retry_max if retry_on_reason_or_always? + max_allowed_retries ||= DEFAULT_RETRIES.fetch(failure_reason.to_sym, 0) + + max_allowed_retries > 0 && retries_count < max_allowed_retries end - def retry_when - normalized_retry.fetch(:when, ['always']) + def options_retry_max + options_retry[:max] end - def retry_failure? - return false if retries_max.zero? || retries_count >= retries_max + def options_retry_when + options_retry.fetch(:when, ['always']) + end - retry_when.include?('always') || retry_when.include?(failure_reason.to_s) + def retry_on_reason_or_always? + options_retry_when.include?(failure_reason.to_s) || + options_retry_when.include?('always') end def latest? @@ -595,6 +609,14 @@ module Ci update_column(:trace, nil) end + def artifacts_expose_as + options.dig(:artifacts, :expose_as) + end + + def artifacts_paths + options.dig(:artifacts, :paths) + end + def needs_touch? Time.now - updated_at > 15.minutes.to_i end @@ -818,6 +840,13 @@ module Ci :creating end + # Consider this object to have a structural integrity problems + def doom! + update_columns( + status: :failed, + failure_reason: :data_integrity_failure) + end + private def successful_deployment_status @@ -862,19 +891,13 @@ module Ci # format, but builds created before GitLab 11.5 and saved in database still # have the old integer only format. This method returns the retry option # normalized as a hash in 11.5+ format. - def normalized_retry - strong_memoize(:normalized_retry) do + def options_retry + strong_memoize(:options_retry) do value = options&.dig(:retry) value = value.is_a?(Integer) ? { max: value } : value.to_h value.with_indifferent_access end end - - def build_attributes_from_config - return {} unless pipeline.config_processor - - pipeline.config_processor.build_attributes(name) - end end end diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 3097e40dd3b..0df5ebfe843 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -27,6 +27,7 @@ module Ci scope :scoped_build, -> { where('ci_builds_metadata.build_id = ci_builds.id') } scope :with_interruptible, -> { where(interruptible: true) } + scope :with_exposed_artifacts, -> { where(has_exposed_artifacts: true) } enum timeout_source: { unknown_timeout_source: 1, diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 3bf19399cec..f730b949ee9 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -405,7 +405,7 @@ module Ci .where('stage=sg.stage').failed_but_allowed.to_sql stages_with_statuses = CommitStatus.from(stages_query, :sg) - .pluck('sg.stage', status_sql, "(#{warnings_sql})") + .pluck('sg.stage', Arel.sql(status_sql), Arel.sql("(#{warnings_sql})")) stages_with_statuses.map do |stage| Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)]) @@ -551,23 +551,6 @@ module Ci end end - def stage_seeds - return [] unless config_processor - - strong_memoize(:stage_seeds) do - seeds = config_processor.stages_attributes.inject([]) do |previous_stages, attributes| - seed = Gitlab::Ci::Pipeline::Seed::Stage.new(self, attributes, previous_stages) - previous_stages + [seed] - end - - seeds.select(&:included?) - end - end - - def seeds_size - stage_seeds.sum(&:size) - end - def has_kubernetes_active? project.deployment_platform&.active? end @@ -587,56 +570,14 @@ module Ci end end - def set_config_source - if ci_yaml_from_repo - self.config_source = :repository_source - elsif implied_ci_yaml_file - self.config_source = :auto_devops_source - end - end - - ## - # TODO, setting yaml_errors should be moved to the pipeline creation chain. - # - def config_processor - return unless ci_yaml_file - return @config_processor if defined?(@config_processor) - - @config_processor ||= begin - ::Gitlab::Ci::YamlProcessor.new(ci_yaml_file, { project: project, sha: sha, user: user }) - rescue Gitlab::Ci::YamlProcessor::ValidationError => e - self.yaml_errors = e.message - nil - rescue - self.yaml_errors = 'Undefined error' - nil - end - end - - def ci_yaml_file_path + # TODO: this logic is duplicate with Pipeline::Chain::Config::Content + # we should persist this is `ci_pipelines.config_path` + def config_path return unless repository_source? || unknown_source? project.ci_config_path.presence || '.gitlab-ci.yml' end - def ci_yaml_file - return @ci_yaml_file if defined?(@ci_yaml_file) - - @ci_yaml_file = - if auto_devops_source? - implied_ci_yaml_file - else - ci_yaml_from_repo - end - - if @ci_yaml_file - @ci_yaml_file - else - self.yaml_errors = "Failed to load CI/CD config file for #{sha}" - nil - end - end - def has_yaml_errors? yaml_errors.present? end @@ -705,7 +646,7 @@ module Ci def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_PIPELINE_IID', value: iid.to_s) - variables.append(key: 'CI_CONFIG_PATH', value: ci_yaml_file_path) + variables.append(key: 'CI_CONFIG_PATH', value: config_path) variables.append(key: 'CI_PIPELINE_SOURCE', value: source.to_s) variables.append(key: 'CI_COMMIT_MESSAGE', value: git_commit_message.to_s) variables.append(key: 'CI_COMMIT_TITLE', value: git_commit_full_title.to_s) @@ -783,6 +724,10 @@ module Ci end end + def has_exposed_artifacts? + complete? && builds.latest.with_exposed_artifacts.exists? + end + def branch_updated? strong_memoize(:branch_updated) do push_details.branch_updated? @@ -896,24 +841,6 @@ module Ci private - def ci_yaml_from_repo - return unless project - return unless sha - return unless ci_yaml_file_path - - project.repository.gitlab_ci_yml_for(sha, ci_yaml_file_path) - rescue GRPC::NotFound, GRPC::Internal - nil - end - - def implied_ci_yaml_file - return unless project - - if project.auto_devops_enabled? - Gitlab::Template::GitlabCiYmlTemplate.find('Auto-DevOps').content - end - end - def pipeline_data Gitlab::DataBuilder::Pipeline.build(self) end diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index 18cbf827a67..7ba04d1a2de 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -65,7 +65,7 @@ module Clusters end def retry_command(command) - "for i in $(seq 1 30); do #{command} && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)" + "for i in $(seq 1 90); do #{command} && s=0 && break || s=$?; sleep 1s; echo \"Retrying ($i)...\"; done; (exit $s)" end def post_delete_script diff --git a/app/models/clusters/applications/crossplane.rb b/app/models/clusters/applications/crossplane.rb new file mode 100644 index 00000000000..36246b26066 --- /dev/null +++ b/app/models/clusters/applications/crossplane.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class Crossplane < ApplicationRecord + VERSION = '0.4.1' + + self.table_name = 'clusters_applications_crossplane' + + include ::Clusters::Concerns::ApplicationCore + include ::Clusters::Concerns::ApplicationStatus + include ::Clusters::Concerns::ApplicationVersion + include ::Clusters::Concerns::ApplicationData + + default_value_for :version, VERSION + + default_value_for :stack do |crossplane| + '' + end + + validates :stack, presence: true + + def chart + 'crossplane/crossplane' + end + + def repository + 'https://charts.crossplane.io/alpha' + end + + def install_command + Gitlab::Kubernetes::Helm::InstallCommand.new( + name: 'crossplane', + repository: repository, + version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, + chart: chart, + files: files + ) + end + + def values + crossplane_values.to_yaml + end + + private + + def crossplane_values + { + "clusterStacks" => { + self.stack => { + "deploy" => true, + "version" => "alpha" + } + } + } + end + end + end +end diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb new file mode 100644 index 00000000000..8589f8c00cb --- /dev/null +++ b/app/models/clusters/applications/elastic_stack.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module Clusters + module Applications + class ElasticStack < ApplicationRecord + VERSION = '1.8.0' + + ELASTICSEARCH_PORT = 9200 + + self.table_name = 'clusters_applications_elastic_stacks' + + include ::Clusters::Concerns::ApplicationCore + include ::Clusters::Concerns::ApplicationStatus + include ::Clusters::Concerns::ApplicationVersion + include ::Clusters::Concerns::ApplicationData + include ::Gitlab::Utils::StrongMemoize + + default_value_for :version, VERSION + + def set_initial_status + return unless not_installable? + return unless cluster&.application_ingress_available? + + ingress = cluster.application_ingress + self.status = status_states[:installable] if ingress.external_ip_or_hostname? + end + + def chart + 'stable/elastic-stack' + end + + def values + content_values.to_yaml + end + + def install_command + Gitlab::Kubernetes::Helm::InstallCommand.new( + name: 'elastic-stack', + version: VERSION, + rbac: cluster.platform_kubernetes_rbac?, + chart: chart, + files: files + ) + end + + def uninstall_command + Gitlab::Kubernetes::Helm::DeleteCommand.new( + name: 'elastic-stack', + rbac: cluster.platform_kubernetes_rbac?, + files: files, + postdelete: post_delete_script + ) + end + + def elasticsearch_client + strong_memoize(:elasticsearch_client) do + next unless kube_client + + proxy_url = kube_client.proxy_url('service', 'elastic-stack-elasticsearch-client', ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE) + + Elasticsearch::Client.new(url: proxy_url) do |faraday| + # ensures headers containing auth data are appended to original client options + faraday.headers.merge!(kube_client.headers) + # ensure TLS certs are properly verified + faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] + faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] + end + + rescue Kubeclient::HttpError => error + # If users have mistakenly set parameters or removed the depended clusters, + # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. + # We check for a nil client in downstream use and behaviour is equivalent to an empty state + log_exception(error, :failed_to_create_elasticsearch_client) + end + end + + private + + def specification + { + "kibana" => { + "ingress" => { + "hosts" => [kibana_hostname], + "tls" => [{ + "hosts" => [kibana_hostname], + "secretName" => "kibana-cert" + }] + } + } + } + end + + def content_values + YAML.load_file(chart_values_file).deep_merge!(specification) + end + + def post_delete_script + [ + Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack") + ].compact + end + + def kube_client + cluster&.kubeclient&.core_client + end + end + end +end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index 885e4ff7197..d140649af3c 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -21,6 +21,7 @@ module Clusters } FETCH_IP_ADDRESS_DELAY = 30.seconds + MODSEC_SIDECAR_INITIAL_DELAY_SECONDS = 10 state_machine :status do after_transition any => [:installed] do |application| @@ -40,7 +41,7 @@ module Clusters end def allowed_to_uninstall? - external_ip_or_hostname? && application_jupyter_nil_or_installable? + external_ip_or_hostname? && application_jupyter_nil_or_installable? && application_elastic_stack_nil_or_installable? end def install_command @@ -78,12 +79,74 @@ module Clusters "controller" => { "config" => { "enable-modsecurity" => "true", - "enable-owasp-modsecurity-crs" => "true" - } + "enable-owasp-modsecurity-crs" => "true", + "modsecurity.conf" => modsecurity_config_content + }, + "extraContainers" => [ + { + "name" => "modsecurity-log", + "image" => "busybox", + "args" => [ + "/bin/sh", + "-c", + "tail -f /var/log/modsec/audit.log" + ], + "volumeMounts" => [ + { + "name" => "modsecurity-log-volume", + "mountPath" => "/var/log/modsec", + "readOnly" => true + } + ], + "startupProbe" => { + "exec" => { + "command" => ["ls", "/var/log/modsec"] + }, + "initialDelaySeconds" => MODSEC_SIDECAR_INITIAL_DELAY_SECONDS + } + } + ], + "extraVolumeMounts" => [ + { + "name" => "modsecurity-template-volume", + "mountPath" => "/etc/nginx/modsecurity/modsecurity.conf", + "subPath" => "modsecurity.conf" + }, + { + "name" => "modsecurity-log-volume", + "mountPath" => "/var/log/modsec" + } + ], + "extraVolumes" => [ + { + "name" => "modsecurity-template-volume", + "configMap" => { + "name" => "ingress-nginx-ingress-controller", + "items" => [ + { + "key" => "modsecurity.conf", + "path" => "modsecurity.conf" + } + ] + } + }, + { + "name" => "modsecurity-log-volume", + "emptyDir" => {} + } + ] } } end + def modsecurity_config_content + File.read(modsecurity_config_file_path) + end + + def modsecurity_config_file_path + Rails.root.join('vendor', 'ingress', 'modsecurity.conf') + end + def content_values YAML.load_file(chart_values_file).deep_merge!(specification) end @@ -91,6 +154,10 @@ module Clusters def application_jupyter_nil_or_installable? cluster.application_jupyter.nil? || cluster.application_jupyter&.installable? end + + def application_elastic_stack_nil_or_installable? + cluster.application_elastic_stack.nil? || cluster.application_elastic_stack&.installable? + end end end end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 954046c143b..37ba8a7c97e 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.9.0' + VERSION = '0.10.1' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index d6f5d7c3f93..f522f3f2fdb 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -6,20 +6,21 @@ module Clusters include Gitlab::Utils::StrongMemoize include FromUnion include ReactiveCaching + include AfterCommitQueue self.table_name = 'clusters' - PROJECT_ONLY_APPLICATIONS = { - }.freeze APPLICATIONS = { Applications::Helm.application_name => Applications::Helm, Applications::Ingress.application_name => Applications::Ingress, Applications::CertManager.application_name => Applications::CertManager, + Applications::Crossplane.application_name => Applications::Crossplane, Applications::Prometheus.application_name => Applications::Prometheus, Applications::Runner.application_name => Applications::Runner, Applications::Jupyter.application_name => Applications::Jupyter, - Applications::Knative.application_name => Applications::Knative - }.merge(PROJECT_ONLY_APPLICATIONS).freeze + Applications::Knative.application_name => Applications::Knative, + Applications::ElasticStack.application_name => Applications::ElasticStack + }.freeze DEFAULT_ENVIRONMENT = '*' KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN' @@ -47,14 +48,17 @@ module Clusters has_one_cluster_application :helm has_one_cluster_application :ingress has_one_cluster_application :cert_manager + has_one_cluster_application :crossplane has_one_cluster_application :prometheus has_one_cluster_application :runner has_one_cluster_application :jupyter has_one_cluster_application :knative + has_one_cluster_application :elastic_stack has_many :kubernetes_namespaces accepts_nested_attributes_for :provider_gcp, update_only: true + accepts_nested_attributes_for :provider_aws, update_only: true accepts_nested_attributes_for :platform_kubernetes, update_only: true validates :name, cluster_name: true @@ -72,6 +76,7 @@ module Clusters delegate :status, to: :provider, allow_nil: true delegate :status_reason, to: :provider, allow_nil: true delegate :on_creation?, to: :provider, allow_nil: true + delegate :knative_pre_installed?, to: :provider, allow_nil: true delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true delegate :rbac?, to: :platform_kubernetes, prefix: true, allow_nil: true @@ -115,6 +120,8 @@ module Clusters scope :default_environment, -> { where(environment_scope: DEFAULT_ENVIRONMENT) } + scope :for_project_namespace, -> (namespace_id) { joins(:projects).where(projects: { namespace_id: namespace_id }) } + def self.ancestor_clusters_for_clusterable(clusterable, hierarchy_order: :asc) return [] if clusterable.is_a?(Instance) @@ -124,7 +131,55 @@ module Clusters hierarchy_groups.flat_map(&:clusters) + Instance.new.clusters end + state_machine :cleanup_status, initial: :cleanup_not_started do + state :cleanup_not_started, value: 1 + state :cleanup_uninstalling_applications, value: 2 + state :cleanup_removing_project_namespaces, value: 3 + state :cleanup_removing_service_account, value: 4 + state :cleanup_errored, value: 5 + + event :start_cleanup do |cluster| + transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications + end + + event :continue_cleanup do + transition( + cleanup_uninstalling_applications: :cleanup_removing_project_namespaces, + cleanup_removing_project_namespaces: :cleanup_removing_service_account) + end + + event :make_cleanup_errored do + transition any => :cleanup_errored + end + + before_transition any => [:cleanup_errored] do |cluster, transition| + status_reason = transition.args.first + cluster.cleanup_status_reason = status_reason if status_reason + end + + after_transition [:cleanup_not_started, :cleanup_errored] => :cleanup_uninstalling_applications do |cluster| + cluster.run_after_commit do + Clusters::Cleanup::AppWorker.perform_async(cluster.id) + end + end + + after_transition cleanup_uninstalling_applications: :cleanup_removing_project_namespaces do |cluster| + cluster.run_after_commit do + Clusters::Cleanup::ProjectNamespaceWorker.perform_async(cluster.id) + end + end + + after_transition cleanup_removing_project_namespaces: :cleanup_removing_service_account do |cluster| + cluster.run_after_commit do + Clusters::Cleanup::ServiceAccountWorker.perform_async(cluster.id) + end + end + end + def status_name + return cleanup_status_name if cleanup_errored? + return :cleanup_ongoing unless cleanup_not_started? + provider&.status_name || connection_status.presence || :created end @@ -207,10 +262,6 @@ module Clusters end end - def knative_pre_installed? - provider&.knative_pre_installed? - end - private def unique_management_project_environment_scope diff --git a/app/models/clusters/clusters_hierarchy.rb b/app/models/clusters/clusters_hierarchy.rb index a906eb2888b..c9c18d8c96a 100644 --- a/app/models/clusters/clusters_hierarchy.rb +++ b/app/models/clusters/clusters_hierarchy.rb @@ -20,7 +20,7 @@ module Clusters .with .recursive(cte.to_arel) .from(cte_alias) - .order(DEPTH_COLUMN => :asc) + .order(depth_order_clause) end private @@ -40,7 +40,7 @@ module Clusters end if clusterable.is_a?(::Project) && include_management_project - cte << management_clusters_query + cte << same_namespace_management_clusters_query end cte << base_query @@ -49,13 +49,42 @@ module Clusters cte end + # Returns project-level clusters where the project is the management project + # for the cluster. The management project has to be in the same namespace / + # group as the cluster's project. + # + # Support for management project in sub-groups is planned in + # https://gitlab.com/gitlab-org/gitlab/issues/34650 + # + # NB: group_parent_id is un-used but we still need to match the same number of + # columns as other queries in the CTE. + def same_namespace_management_clusters_query + clusterable.management_clusters + .project_type + .select([clusters_star, 'NULL AS group_parent_id', "0 AS #{DEPTH_COLUMN}"]) + .for_project_namespace(clusterable.namespace_id) + end + # Management clusters should be first in the hierarchy so we use 0 for the # depth column. # - # group_parent_id is un-used but we still need to match the same number of - # columns as other queries in the CTE. - def management_clusters_query - clusterable.management_clusters.select([clusters_star, 'NULL AS group_parent_id', "0 AS #{DEPTH_COLUMN}"]) + # Only applicable if the clusterable is a project (most especially when + # requesting project.deployment_platform). + def depth_order_clause + return { DEPTH_COLUMN => :asc } unless clusterable.is_a?(::Project) && include_management_project + + order = <<~SQL + (CASE clusters.management_project_id + WHEN :project_id THEN 0 + ELSE #{DEPTH_COLUMN} + END) ASC + SQL + + values = { + project_id: clusterable.id + } + + model.sanitize_sql_array([Arel.sql(order), values]) end def group_clusters_base_query diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index 979cf0645f5..21b98534808 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -60,6 +60,24 @@ module Clusters # Override if your application needs any action after # being uninstalled by Helm end + + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def log_exception(error, event) + logger.error({ + exception: error.class.name, + status_code: error.error_code, + cluster_id: cluster&.id, + application_id: id, + class_name: self.class.name, + event: event, + message: error.message + }) + + Gitlab::Sentry.track_acceptable_exception(error, extra: { cluster_id: cluster&.id, application_id: id }) + end end end end diff --git a/app/models/clusters/instance.rb b/app/models/clusters/instance.rb index f21dbdf7f26..8c9d9ab9ab1 100644 --- a/app/models/clusters/instance.rb +++ b/app/models/clusters/instance.rb @@ -9,5 +9,9 @@ module Clusters def feature_available?(feature) ::Feature.enabled?(feature, default_enabled: true) end + + def flipper_id + self.class.to_s + end end end diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb index ae4156896bc..78eb75ddcc0 100644 --- a/app/models/clusters/providers/aws.rb +++ b/app/models/clusters/providers/aws.rb @@ -3,12 +3,12 @@ module Clusters module Providers class Aws < ApplicationRecord + include Gitlab::Utils::StrongMemoize include Clusters::Concerns::ProviderStatus self.table_name = 'cluster_providers_aws' belongs_to :cluster, inverse_of: :provider_aws, class_name: 'Clusters::Cluster' - belongs_to :created_by_user, class_name: 'User' default_value_for :region, 'us-east-1' default_value_for :num_nodes, 3 @@ -42,6 +42,30 @@ module Clusters session_token: nil ) end + + def api_client + strong_memoize(:api_client) do + ::Aws::CloudFormation::Client.new(credentials: credentials, region: region) + end + end + + def credentials + strong_memoize(:credentials) do + ::Aws::Credentials.new(access_key_id, secret_access_key, session_token) + end + end + + def has_rbac_enabled? + true + end + + def knative_pre_installed? + false + end + + def created_by_user + cluster.user + end end end end diff --git a/app/models/clusters/providers/gcp.rb b/app/models/clusters/providers/gcp.rb index f871674676f..2ca7d0249dc 100644 --- a/app/models/clusters/providers/gcp.rb +++ b/app/models/clusters/providers/gcp.rb @@ -54,6 +54,10 @@ module Clusters assign_attributes(operation_id: operation_id) end + def has_rbac_enabled? + !legacy_abac + end + def knative_pre_installed? cloud_run? end diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb index a540e291990..2ca6d15e642 100644 --- a/app/models/commit_status_enums.rb +++ b/app/models/commit_status_enums.rb @@ -15,7 +15,9 @@ module CommitStatusEnums stale_schedule: 7, job_execution_timeout: 8, archived_failure: 9, - unmet_prerequisites: 10 + unmet_prerequisites: 10, + scheduler_failure: 11, + data_integrity_failure: 12 } end end diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index 54e9a13d1ea..0e07806dd6f 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -4,19 +4,28 @@ module Analytics module CycleAnalytics module Stage extend ActiveSupport::Concern + include RelativePositioning + include Gitlab::Utils::StrongMemoize included do + belongs_to :start_event_label, class_name: 'GroupLabel', optional: true + belongs_to :end_event_label, class_name: 'GroupLabel', optional: true + validates :name, presence: true validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom? validates :start_event_identifier, presence: true validates :end_event_identifier, presence: true + validates :start_event_label, presence: true, if: :start_event_label_based? + validates :end_event_label, presence: true, if: :end_event_label_based? validate :validate_stage_event_pairs + validate :validate_labels enum start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :start_event_identifier enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier alias_attribute :custom_stage?, :custom scope :default_stages, -> { where(custom: false) } + scope :ordered, -> { order(:relative_position, :id) } end def parent=(_) @@ -28,19 +37,41 @@ module Analytics end def start_event - Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event) + strong_memoize(:start_event) do + Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event) + end end def end_event - Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event) + strong_memoize(:end_event) do + Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event) + end + end + + def start_event_label_based? + start_event_identifier && start_event.label_based? + end + + def end_event_label_based? + end_event_identifier && end_event.label_based? + end + + def start_event_identifier=(identifier) + clear_memoization(:start_event) + super + end + + def end_event_identifier=(identifier) + clear_memoization(:end_event) + super end def params_for_start_event - {} + start_event_label.present? ? { label: start_event_label } : {} end def params_for_end_event - {} + end_event_label.present? ? { label: end_event_label } : {} end def default_stage? @@ -58,19 +89,44 @@ module Analytics end_event_identifier.to_s.eql?(stage_params[:end_event_identifier].to_s) end + def find_with_same_parent!(id) + parent.cycle_analytics_stages.find(id) + end + private def validate_stage_event_pairs return if start_event_identifier.nil? || end_event_identifier.nil? unless pairing_rules.fetch(start_event.class, []).include?(end_event.class) - errors.add(:end_event, :not_allowed_for_the_given_start_event) + errors.add(:end_event, s_('CycleAnalytics|not allowed for the given start event')) end end def pairing_rules Gitlab::Analytics::CycleAnalytics::StageEvents.pairing_rules end + + def validate_labels + validate_label_within_group(:start_event_label, start_event_label_id) if start_event_label_id_changed? + validate_label_within_group(:end_event_label, end_event_label_id) if end_event_label_id_changed? + end + + def validate_label_within_group(association_name, label_id) + return unless label_id + return unless group + + unless label_available_for_group?(label_id) + errors.add(association_name, s_('CycleAnalyticsStage|is not available for the selected group')) + end + end + + def label_available_for_group?(label_id) + LabelsFinder.new(nil, { group_id: group.id, include_ancestor_groups: true, only_group_labels: true }) + .execute(skip_authorization: true) + .by_ids(label_id) + .exists? + end end end end diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index a0ca8a34c6d..17d431bacf2 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -16,6 +16,7 @@ module Ci delegate :timeout, to: :metadata, prefix: true, allow_nil: true delegate :interruptible, to: :metadata, prefix: false, allow_nil: true + delegate :has_exposed_artifacts?, to: :metadata, prefix: false, allow_nil: true before_create :ensure_metadata end @@ -45,6 +46,9 @@ module Ci def options=(value) write_metadata_attribute(:options, :config_options, value) + + # Store presence of exposed artifacts in build metadata to make it easier to query + ensure_metadata.has_exposed_artifacts = value&.dig(:artifacts, :expose_as).present? end def yaml_variables=(value) diff --git a/app/models/concerns/ci/processable.rb b/app/models/concerns/ci/processable.rb index 268fa8ec692..ed0087f34d4 100644 --- a/app/models/concerns/ci/processable.rb +++ b/app/models/concerns/ci/processable.rb @@ -8,6 +8,14 @@ module Ci # # module Processable + extend ActiveSupport::Concern + + included do + has_many :needs, class_name: 'Ci::BuildNeed', foreign_key: :build_id, inverse_of: :build + + accepts_nested_attributes_for :needs + end + def schedulable? raise NotImplementedError end diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb index fe8e9609820..3b893a56bd6 100644 --- a/app/models/concerns/deployment_platform.rb +++ b/app/models/concerns/deployment_platform.rb @@ -12,7 +12,7 @@ module DeploymentPlatform private def cluster_management_project_enabled? - Feature.enabled?(:cluster_management_project, default_enabled: true) + Feature.enabled?(:cluster_management_project, self, default_enabled: true) end def find_deployment_platform(environment) diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 852576dbbc2..01cd1e0224b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -118,8 +118,8 @@ module Issuable # rubocop:enable GitlabSecurity/SqlInjection scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } - scope :order_milestone_due_desc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') } - scope :order_milestone_due_asc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') } + scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) } + scope :order_milestone_due_asc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC')) } 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 :any_label, -> { joins(:label_links).group(:id) } @@ -137,6 +137,26 @@ module Issuable strip_attributes :title + # The state_machine gem will reset the value of state_id unless it + # is a raw attribute passed in here: + # https://gitlab.com/gitlab-org/gitlab/issues/35746#note_241148787 + # + # This assumes another initialize isn't defined. Otherwise this + # method may need to be prepended. + def initialize(attributes = nil) + if attributes.is_a?(Hash) + attr = attributes.symbolize_keys + + if attr.key?(:state) && !attr.key?(:state_id) + value = attr.delete(:state) + state_id = self.class.available_states[value] + attributes[:state_id] = state_id if state_id + end + end + + super(attributes) + end + # We want to use optimistic lock for cases when only title or description are involved # http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html def locking_enabled? diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 42b370990ac..b1a7d7ec819 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -101,6 +101,10 @@ module Milestoneish false end + def global_milestone? + false + end + def total_issue_time_spent @total_issue_time_spent ||= issues.joins(:timelogs).sum(:time_spent) end diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index 3065e0ba6c5..19f2daa1b01 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -108,10 +108,6 @@ module Noteable discussions_resolvable? && resolvable_discussions.none?(&:to_be_resolved?) end - def discussions_to_be_resolved? - discussions_resolvable? && !discussions_resolved? - end - def discussions_to_be_resolved @discussions_to_be_resolved ||= resolvable_discussions.select(&:to_be_resolved?) end diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index ebacc459cb5..d9a7f0a96dc 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -39,8 +39,8 @@ module ProtectedRef end end - def developers_can?(action, ref) - access_levels_for_ref(ref, action: action).any? do |access_level| + def developers_can?(action, ref, protected_refs: nil) + access_levels_for_ref(ref, action: action, protected_refs: protected_refs).any? do |access_level| access_level.access_level == Gitlab::Access::DEVELOPER end end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index 78544405c49..9c2b0372d54 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -55,20 +55,22 @@ module Storage def move_repositories # Move the namespace directory in all storages used by member projects - repository_storages.each do |repository_storage| + repository_storages(legacy_only: true).each do |repository_storage| # Ensure old directory exists before moving it - gitlab_shell.add_namespace(repository_storage, full_path_before_last_save) + Gitlab::GitalyClient::NamespaceService.allow do + gitlab_shell.add_namespace(repository_storage, full_path_before_last_save) - # Ensure new directory exists before moving it (if there's a parent) - gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent + # Ensure new directory exists before moving it (if there's a parent) + gitlab_shell.add_namespace(repository_storage, parent.full_path) if parent - unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path) + unless gitlab_shell.mv_namespace(repository_storage, full_path_before_last_save, full_path) - Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}" # rubocop:disable Gitlab/RailsLogger + Rails.logger.error "Exception moving path #{repository_storage} from #{full_path_before_last_save} to #{full_path}" # rubocop:disable Gitlab/RailsLogger - # if we cannot move namespace directory we should rollback - # db changes in order to prevent out of sync between db and fs - raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + # if we cannot move namespace directory we should rollback + # db changes in order to prevent out of sync between db and fs + raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + end end end end @@ -77,12 +79,14 @@ module Storage @old_repository_storage_paths ||= repository_storages end - def repository_storages + def repository_storages(legacy_only: false) # We need to get the storage paths for all the projects, even the ones that are # pending delete. Unscoping also get rids of the default order, which causes # problems with SELECT DISTINCT. Project.unscoped do - all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage) + namespace_projects = all_projects + namespace_projects = namespace_projects.without_storage_feature(:repository) if legacy_only + namespace_projects.pluck(Arel.sql('distinct(repository_storage)')) end end @@ -93,13 +97,15 @@ module Storage # We will remove it later async new_path = "#{full_path}+#{id}+deleted" - if gitlab_shell.mv_namespace(repository_storage, full_path, new_path) - Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}") + Gitlab::GitalyClient::NamespaceService.allow do + if gitlab_shell.mv_namespace(repository_storage, full_path, new_path) + Gitlab::AppLogger.info %Q(Namespace directory "#{full_path}" moved to "#{new_path}") - # Remove namespace directory async with delay so - # GitLab has time to remove all projects first - run_after_commit do - GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage, new_path) + # Remove namespace directory async with delay so + # GitLab has time to remove all projects first + run_after_commit do + GitlabShellWorker.perform_in(5.minutes, :rm_namespace, repository_storage, new_path) + end end end end diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index 92a5c1112af..33e9e0e38fb 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -59,6 +59,14 @@ module Subscribable .update(subscribed: false) end + def set_subscription(user, desired_state, project = nil) + if desired_state + subscribe(user, project) + else + unsubscribe(user, project) + end + end + private def unsubscribe_from_other_levels(user, project) diff --git a/app/models/concerns/worker_attributes.rb b/app/models/concerns/worker_attributes.rb index af40e9e3b19..506215ca9ed 100644 --- a/app/models/concerns/worker_attributes.rb +++ b/app/models/concerns/worker_attributes.rb @@ -3,6 +3,10 @@ module WorkerAttributes extend ActiveSupport::Concern + # Resource boundaries that workers can declare through the + # `worker_resource_boundary` attribute + VALID_RESOURCE_BOUNDARIES = [:memory, :cpu, :unknown].freeze + class_methods do def feature_category(value) raise "Invalid category. Use `feature_category_not_owned!` to mark a worker as not owned" if value == :not_owned @@ -24,6 +28,48 @@ module WorkerAttributes get_worker_attribute(:feature_category) == :not_owned end + # This should be set for jobs that need to be run immediately, or, if + # they are delayed, risk creating inconsistencies in the application + # that could being perceived by the user as incorrect behavior + # (ie, a bug) + # See doc/development/sidekiq_style_guide.md#Latency-Sensitive-Jobs + # for details + def latency_sensitive_worker! + worker_attributes[:latency_sensitive] = true + end + + # Returns a truthy value if the worker is latency sensitive. + # See doc/development/sidekiq_style_guide.md#Latency-Sensitive-Jobs + # for details + def latency_sensitive_worker? + worker_attributes[:latency_sensitive] + end + + # Set this attribute on a job when it will call to services outside of the + # application, such as 3rd party applications, other k8s clusters etc See + # doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies for + # details + def worker_has_external_dependencies! + worker_attributes[:external_dependencies] = true + end + + # Returns a truthy value if the worker has external dependencies. + # See doc/development/sidekiq_style_guide.md#Jobs-with-External-Dependencies + # for details + def worker_has_external_dependencies? + worker_attributes[:external_dependencies] + end + + def worker_resource_boundary(boundary) + raise "Invalid boundary" unless VALID_RESOURCE_BOUNDARIES.include? boundary + + worker_attributes[:resource_boundary] = boundary + end + + def get_worker_resource_boundary + worker_attributes[:resource_boundary] || :unknown + end + protected # Returns a worker attribute declared on this class or its parent class. diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 27bb76835c7..152aa7b3218 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -11,7 +11,10 @@ class ContainerRepository < ApplicationRecord delegate :client, to: :registry scope :ordered, -> { order(:name) } - scope :with_api_entity_associations, -> { preload(:project) } + scope :with_api_entity_associations, -> { preload(project: [:route, { namespace: :route }]) } + scope :for_group_and_its_subgroups, ->(group) do + where(project_id: Project.for_group_and_its_subgroups(group).with_container_registry.select(:id)) + end # rubocop: disable CodeReuse/ServiceClass def registry diff --git a/app/models/dashboard_group_milestone.rb b/app/models/dashboard_group_milestone.rb index ec52f1ed370..cf6094682f3 100644 --- a/app/models/dashboard_group_milestone.rb +++ b/app/models/dashboard_group_milestone.rb @@ -18,4 +18,8 @@ class DashboardGroupMilestone < GlobalMilestone milestones = milestones.search_title(params[:search_title]) if params[:search_title].present? Milestone.filter_by_state(milestones, params[:state]).map { |m| new(m) } end + + def dashboard_milestone? + true + end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 7ccd5e98360..4a38912db9b 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -10,6 +10,10 @@ class Deployment < ApplicationRecord belongs_to :cluster, class_name: 'Clusters::Cluster', optional: true belongs_to :user belongs_to :deployable, polymorphic: true, optional: true # rubocop:disable Cop/PolymorphicAssociations + has_many :deployment_merge_requests + + has_many :merge_requests, + through: :deployment_merge_requests has_internal_id :iid, scope: :project, init: ->(s) do Deployment.where(project: s.project).maximum(:iid) if s&.project @@ -75,6 +79,11 @@ class Deployment < ApplicationRecord find(ids) end + def self.distinct_on_environment + order('environment_id, deployments.id DESC') + .select('DISTINCT ON (environment_id) deployments.*') + end + def self.find_successful_deployment!(iid) success.find_by!(iid: iid) end @@ -144,6 +153,18 @@ class Deployment < ApplicationRecord project.deployments.joins(:environment) .where(environments: { name: self.environment.name }, ref: self.ref) .where.not(id: self.id) + .order(id: :desc) + .take + end + + def previous_environment_deployment + project + .deployments + .success + .joins(:environment) + .where(environments: { name: environment.name }) + .where.not(id: self.id) + .order(id: :desc) .take end @@ -176,6 +197,18 @@ class Deployment < ApplicationRecord deployable&.user || user end + def link_merge_requests(relation) + select = relation.select(['merge_requests.id', id]).to_sql + + # We don't use `Gitlab::Database.bulk_insert` here so that we don't need to + # first pluck lots of IDs into memory. + DeploymentMergeRequest.connection.execute(<<~SQL) + INSERT INTO #{DeploymentMergeRequest.table_name} + (merge_request_id, deployment_id) + #{select} + SQL + end + private def ref_path diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb new file mode 100644 index 00000000000..ff4d9f66202 --- /dev/null +++ b/app/models/deployment_merge_request.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class DeploymentMergeRequest < ApplicationRecord + belongs_to :deployment, optional: false + belongs_to :merge_request, optional: false +end diff --git a/app/models/description_version.rb b/app/models/description_version.rb index abab7f94212..05362a2f90b 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -10,6 +10,10 @@ class DescriptionVersion < ApplicationRecord %i(issue merge_request).freeze end + def issuable + issue || merge_request + end + private def exactly_one_issuable diff --git a/app/models/environment.rb b/app/models/environment.rb index af0c219d9a0..327b1e594d7 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -4,12 +4,20 @@ class Environment < ApplicationRecord include Gitlab::Utils::StrongMemoize include ReactiveCaching + self.reactive_cache_refresh_interval = 1.minute + self.reactive_cache_lifetime = 55.seconds + belongs_to :project, required: true has_many :deployments, -> { visible }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' + has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus' + has_one :last_pipeline, through: :last_deployable, source: 'pipeline' + has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment' + has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus' + has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline' before_validation :nullify_external_url before_validation :generate_slug, if: ->(env) { env.slug.blank? } @@ -60,6 +68,10 @@ class Environment < ApplicationRecord scope :for_project, -> (project) { where(project_id: project) } scope :with_deployment, -> (sha) { where('EXISTS (?)', Deployment.select(1).where('deployments.environment_id = environments.id').where(sha: sha)) } + scope :unfoldered, -> { where(environment_type: nil) } + scope :with_rank, -> do + select('environments.*, rank() OVER (PARTITION BY project_id ORDER BY id DESC)') + end state_machine :state, initial: :available do event :start do @@ -188,6 +200,10 @@ class Environment < ApplicationRecord prometheus_adapter.query(:environment, self) if has_metrics? end + def prometheus_status + deployment_platform&.cluster&.application_prometheus&.status_name + end + def additional_metrics(*args) return unless has_metrics? diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 0b4fef5eac1..2aa058a243f 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -7,6 +7,7 @@ module ErrorTracking SENTRY_API_ERROR_TYPE_MISSING_KEYS = 'missing_keys_in_sentry_response' SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE = 'non_20x_response_from_sentry' + SENTRY_API_ERROR_INVALID_SIZE = 'invalid_size_of_sentry_response' API_URL_PATH_REGEXP = %r{ \A @@ -87,15 +88,37 @@ module ErrorTracking { projects: sentry_client.list_projects } end + def issue_details(opts = {}) + with_reactive_cache('issue_details', opts.stringify_keys) do |result| + result + end + end + + def issue_latest_event(opts = {}) + with_reactive_cache('issue_latest_event', opts.stringify_keys) do |result| + result + end + end + def calculate_reactive_cache(request, opts) case request when 'list_issues' { issues: sentry_client.list_issues(**opts.symbolize_keys) } + when 'issue_details' + { + issue: sentry_client.issue_details(**opts.symbolize_keys) + } + when 'issue_latest_event' + { + latest_event: sentry_client.issue_latest_event(**opts.symbolize_keys) + } end rescue Sentry::Client::Error => e { error: e.message, error_type: SENTRY_API_ERROR_TYPE_NON_20X_RESPONSE } rescue Sentry::Client::MissingKeysError => e { error: e.message, error_type: SENTRY_API_ERROR_TYPE_MISSING_KEYS } + rescue Sentry::Client::ResponseInvalidSizeError => e + { error: e.message, error_type: SENTRY_API_ERROR_INVALID_SIZE } end # http://HOST/api/0/projects/ORG/PROJECT diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 7d766e1f25c..65fd5c1b35a 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -11,7 +11,7 @@ class GlobalMilestone delegate :title, :state, :due_date, :start_date, :participants, :project, :group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title, - :milestoneish_id, :resource_parent, to: :milestone + :milestoneish_id, :resource_parent, :releases, to: :milestone def to_hash { @@ -100,4 +100,8 @@ class GlobalMilestone def labels @labels ||= GlobalLabel.build_collection(milestone.labels).sort_by!(&:title) end + + def global_milestone? + true + end end diff --git a/app/models/grafana_integration.rb b/app/models/grafana_integration.rb index 51cc398394d..ed4c279965a 100644 --- a/app/models/grafana_integration.rb +++ b/app/models/grafana_integration.rb @@ -14,7 +14,13 @@ class GrafanaIntegration < ApplicationRecord validates :token, :project, presence: true + validates :enabled, inclusion: { in: [true, false] } + + scope :enabled, -> { where(enabled: true) } + def client + return unless enabled? + @client ||= ::Grafana::Client.new(api_url: grafana_url.chomp('/'), token: token) end end diff --git a/app/models/group.rb b/app/models/group.rb index 042201ffa14..8289d4f099c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -30,6 +30,10 @@ class Group < Namespace has_many :members_and_requesters, as: :source, class_name: 'GroupMember' has_many :milestones + has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink' + has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink' + has_many :shared_groups, through: :shared_group_links, source: :shared_group + has_many :shared_with_groups, through: :shared_with_group_links, source: :shared_with_group has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :shared_projects, through: :project_group_links, source: :project @@ -51,6 +55,8 @@ class Group < Namespace has_many :todos + has_one :import_export_upload + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects @@ -120,7 +126,7 @@ class Group < Namespace def visible_to_user_arel(user) groups_table = self.arel_table - authorized_groups = user.authorized_groups.as('authorized') + authorized_groups = user.authorized_groups.arel.as('authorized') groups_table.project(1) .from(authorized_groups) @@ -259,8 +265,8 @@ class Group < Namespace members_with_parents.maintainers.exists?(user_id: user) end - def has_container_repositories? - container_repositories.exists? + def has_container_repository_including_subgroups? + ::ContainerRepository.for_group_and_its_subgroups(self).exists? end # @deprecated @@ -376,11 +382,12 @@ class Group < Namespace return GroupMember::OWNER if user.admin? - members_with_parents - .where(user_id: user) - .reorder(access_level: :desc) - .first&. - access_level || GroupMember::NO_ACCESS + max_member_access = members_with_parents.where(user_id: user) + .reorder(access_level: :desc) + .first + &.access_level + + max_member_access || max_member_access_for_user_from_shared_groups(user) || GroupMember::NO_ACCESS end def mattermost_team_params @@ -444,6 +451,14 @@ class Group < Namespace false end + def export_file_exists? + export_file&.file + end + + def export_file + import_export_upload&.export_file + end + private def update_two_factor_requirement @@ -474,6 +489,26 @@ class Group < Namespace errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.") end + def max_member_access_for_user_from_shared_groups(user) + return unless Feature.enabled?(:share_group_with_group) + + group_group_link_table = GroupGroupLink.arel_table + group_member_table = GroupMember.arel_table + + group_group_links_query = GroupGroupLink.where(shared_group_id: self_and_ancestors_ids) + cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query) + + link = GroupGroupLink + .with(cte.to_arel) + .from([group_member_table, cte.alias_to(group_group_link_table)]) + .where(group_member_table[:user_id].eq(user.id)) + .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id])) + .reorder(Arel::Nodes::Descending.new(group_group_link_table[:group_access])) + .first + + link&.group_access + end + def self.groups_including_descendants_by(group_ids) Gitlab::ObjectHierarchy .new(Group.where(id: group_ids)) diff --git a/app/models/group_group_link.rb b/app/models/group_group_link.rb new file mode 100644 index 00000000000..4b279b7af5b --- /dev/null +++ b/app/models/group_group_link.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class GroupGroupLink < ApplicationRecord + include Expirable + + belongs_to :shared_group, class_name: 'Group', foreign_key: :shared_group_id + belongs_to :shared_with_group, class_name: 'Group', foreign_key: :shared_with_group_id + + validates :shared_group, presence: true + validates :shared_group_id, uniqueness: { scope: [:shared_with_group_id], + message: _('The group has already been shared with this group') } + validates :shared_with_group, presence: true + validates :group_access, inclusion: { in: Gitlab::Access.values }, + presence: true + + def self.access_options + Gitlab::Access.options + end + + def self.default_access + Gitlab::Access::DEVELOPER + end +end diff --git a/app/models/import_export_upload.rb b/app/models/import_export_upload.rb index 60f5491849a..7d73fd281f1 100644 --- a/app/models/import_export_upload.rb +++ b/app/models/import_export_upload.rb @@ -5,6 +5,7 @@ class ImportExportUpload < ApplicationRecord include ObjectStorage::BackgroundMove belongs_to :project + belongs_to :group # These hold the project Import/Export archives (.tar.gz files) mount_uploader :import_file, ImportExportUploader diff --git a/app/models/issue.rb b/app/models/issue.rb index b9b481ac29b..948cadc34e5 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -40,6 +40,7 @@ class Issue < ApplicationRecord has_many :issue_assignees has_many :assignees, class_name: "User", through: :issue_assignees + has_many :zoom_meetings validates :project, presence: true @@ -54,9 +55,9 @@ class Issue < ApplicationRecord scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) } scope :due_tomorrow, -> { where(due_date: Date.tomorrow) } - scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') } - scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') } - scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') } + scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) } + scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) } + scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } scope :preload_associations, -> { preload(:labels, project: :namespace) } @@ -65,6 +66,8 @@ class Issue < ApplicationRecord scope :public_only, -> { where(confidential: false) } scope :confidential_only, -> { where(confidential: true) } + scope :counts_by_state, -> { reorder(nil).group(:state).count } + after_commit :expire_etag_cache after_save :ensure_metrics, unless: :imported? @@ -137,8 +140,8 @@ class Issue < ApplicationRecord def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date - when 'due_date', 'due_date_asc' then order_due_date_asc - when 'due_date_desc' then order_due_date_desc + when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc + when 'due_date_desc' then order_due_date_desc.with_order_id_desc when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc else super @@ -206,7 +209,16 @@ class Issue < ApplicationRecord if self.confidential? "#{iid}-confidential-issue" else - "#{iid}-#{title.parameterize}" + branch_name = "#{iid}-#{title.parameterize}" + + if branch_name.length > 100 + truncated_string = branch_name[0, 100] + # Delete everything dangling after the last hyphen so as not to risk + # existence of unintended words in the branch name due to mid-word split. + branch_name = truncated_string[0, truncated_string.rindex("-")] + end + + branch_name end end diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 535c3cf2ba1..48c971194c6 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -18,6 +18,11 @@ class LfsObject < ApplicationRecord after_save :update_file_store, if: :saved_change_to_file? + def self.not_linked_to_project(project) + where('NOT EXISTS (?)', + project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) + end + def update_file_store # The file.object_store is set during `uploader.store!` # which happens after object is inserted/updated diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 32741046f39..7e1898e7142 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -16,6 +16,9 @@ class MergeRequest < ApplicationRecord include ReactiveCaching include FromUnion include DeprecatedAssignee + include ShaAttribute + + sha_attribute :squash_commit_sha self.reactive_cache_key = ->(model) { [model.project.id, model.iid] } self.reactive_cache_refresh_interval = 10.minutes @@ -65,6 +68,7 @@ class MergeRequest < ApplicationRecord has_many :cached_closes_issues, through: :merge_requests_closing_issues, source: :issue has_many :pipelines_for_merge_request, foreign_key: 'merge_request_id', class_name: 'Ci::Pipeline' has_many :suggestions, through: :notes + has_many :unresolved_notes, -> { unresolved }, as: :noteable, class_name: 'Note' has_many :merge_request_assignees has_many :assignees, class_name: "User", through: :merge_request_assignees @@ -202,11 +206,14 @@ class MergeRequest < ApplicationRecord scope :by_commit_sha, ->(sha) do where('EXISTS (?)', MergeRequestDiff.select(1).where('merge_requests.latest_merge_request_diff_id = merge_request_diffs.id').by_commit_sha(sha)).reorder(nil) end + scope :by_merge_commit_sha, -> (sha) do + where(merge_commit_sha: sha) + end scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } scope :with_api_entity_associations, -> { - preload(:assignees, :author, :notes, :labels, :milestone, :timelogs, - latest_merge_request_diff: [:merge_request_diff_commits], + preload(:assignees, :author, :unresolved_notes, :labels, :milestone, + :timelogs, :latest_merge_request_diff, metrics: [:latest_closed_by, :merged_by], target_project: [:route, { namespace: :route }], source_project: [:route, { namespace: :route }]) @@ -217,17 +224,27 @@ class MergeRequest < ApplicationRecord scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } scope :preload_source_project, -> { preload(:source_project) } - scope :with_open_merge_when_pipeline_succeeds, -> do - with_state(:opened).where(merge_when_pipeline_succeeds: true) + scope :with_auto_merge_enabled, -> do + with_state(:opened).where(auto_merge_enabled: true) end after_save :keep_around_commit alias_attribute :project, :target_project alias_attribute :project_id, :target_project_id + + # Currently, `merge_when_pipeline_succeeds` column is used as a flag + # to check if _any_ auto merge strategy is activated on the merge request. + # Today, we have multiple strategies and MWPS is one of them. + # we'd eventually rename the column for avoiding confusions, but in the mean time + # please use `auto_merge_enabled` alias instead of `merge_when_pipeline_succeeds`. alias_attribute :auto_merge_enabled, :merge_when_pipeline_succeeds alias_method :issuing_parent, :target_project + RebaseLockTimeout = Class.new(StandardError) + + REBASE_LOCK_MESSAGE = _("Failed to enqueue the rebase operation, possibly due to a long-lived transaction. Try again later.") + def self.reference_prefix '!' end @@ -357,11 +374,12 @@ class MergeRequest < ApplicationRecord "#{project.to_reference(from, full: full)}#{reference}" end - def commits - return merge_request_diff.commits if persisted? + def commits(limit: nil) + return merge_request_diff.commits(limit: limit) if persisted? commits_arr = if compare_commits - compare_commits.reverse + reversed_commits = compare_commits.reverse + limit ? reversed_commits.take(limit) : reversed_commits else [] end @@ -369,6 +387,10 @@ class MergeRequest < ApplicationRecord CommitCollection.new(source_project, commits_arr, source_branch) end + def recent_commits + commits(limit: MergeRequestDiff::COMMITS_SAFE_SIZE) + end + def commits_count if persisted? merge_request_diff.commits_count @@ -379,14 +401,17 @@ class MergeRequest < ApplicationRecord end end - def commit_shas - if persisted? - merge_request_diff.commit_shas - elsif compare_commits - compare_commits.to_a.reverse.map(&:sha) - else - Array(diff_head_sha) - end + def commit_shas(limit: nil) + return merge_request_diff.commit_shas(limit: limit) if persisted? + + shas = + if compare_commits + compare_commits.to_a.reverse.map(&:sha) + else + Array(diff_head_sha) + end + + limit ? shas.take(limit) : shas end # Returns true if there are commits that match at least one commit SHA. @@ -417,9 +442,7 @@ class MergeRequest < ApplicationRecord # Set off a rebase asynchronously, atomically updating the `rebase_jid` of # the MR so that the status of the operation can be tracked. def rebase_async(user_id) - transaction do - lock! - + with_rebase_lock do raise ActiveRecord::StaleObjectError if !open? || rebase_in_progress? # Although there is a race between setting rebase_jid here and clearing it @@ -782,6 +805,8 @@ class MergeRequest < ApplicationRecord end def check_mergeability + return if Feature.enabled?(:merge_requests_conditional_mergeability_check, default_enabled: true) && !recheck_merge_status? + MergeRequests::MergeabilityCheckService.new(self).execute(retry_lease: false) end # rubocop: enable CodeReuse/ServiceClass @@ -896,7 +921,7 @@ class MergeRequest < ApplicationRecord def commit_notes # Fetch comments only from last 100 commits - commit_ids = commit_shas.take(100) + commit_ids = commit_shas(limit: 100) Note .user @@ -907,7 +932,7 @@ class MergeRequest < ApplicationRecord def mergeable_discussions_state? return true unless project.only_allow_merge_if_all_discussions_are_resolved? - !discussions_to_be_resolved? + unresolved_notes.none?(&:to_be_resolved?) end def for_fork? @@ -1087,7 +1112,7 @@ class MergeRequest < ApplicationRecord return true unless project.only_allow_merge_if_pipeline_succeeds? return false unless actual_head_pipeline - actual_head_pipeline.success? || actual_head_pipeline.skipped? + actual_head_pipeline.success? end def environments_for(current_user) @@ -1263,6 +1288,27 @@ class MergeRequest < ApplicationRecord compare_reports(Ci::CompareTestReportsService) end + def has_exposed_artifacts? + return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true) + + actual_head_pipeline&.has_exposed_artifacts? + 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. + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 + def find_exposed_artifacts + unless has_exposed_artifacts? + return { status: :error, status_reason: 'This merge request does not have exposed artifacts' } + end + + compare_reports(Ci::GenerateExposedArtifactsReportService) + end + + # TODO: consider renaming this as with exposed artifacts we generate reports, + # not always compare + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 def compare_reports(service_class, current_user = nil) with_reactive_cache(service_class.name, current_user&.id) do |data| unless service_class.new(project, current_user) @@ -1277,6 +1323,8 @@ class MergeRequest < ApplicationRecord def calculate_reactive_cache(identifier, current_user_id = nil, *args) service_class = identifier.constantize + # TODO: the type check should change to something that includes exposed artifacts service + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 raise NameError, service_class unless service_class < Ci::CompareReportsBaseService current_user = User.find_by(id: current_user_id) @@ -1453,6 +1501,30 @@ class MergeRequest < ApplicationRecord private + def with_rebase_lock + if Feature.enabled?(:merge_request_rebase_nowait_lock, default_enabled: true) + with_retried_nowait_lock { yield } + else + with_lock(true) { yield } + end + end + + # If the merge request is idle in transaction or has a SELECT FOR + # UPDATE, we don't want to block indefinitely or this could cause a + # queue of SELECT FOR UPDATE calls. Instead, try to get the lock for + # 5 s before raising an error to the user. + def with_retried_nowait_lock + # Try at most 0.25 + (1.5 * .25) + (1.5^2 * .25) ... (1.5^5 * .25) = 5.2 s to get the lock + Retriable.retriable(on: ActiveRecord::LockWaitTimeout, tries: 6, base_interval: 0.25) do + with_lock('FOR UPDATE NOWAIT') do + yield + end + end + rescue ActiveRecord::LockWaitTimeout => e + Gitlab::Sentry.track_acceptable_exception(e) + raise RebaseLockTimeout, REBASE_LOCK_MESSAGE + end + def source_project_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless source_project diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 735ad046f22..70ce4df5678 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -213,12 +213,14 @@ class MergeRequestDiff < ApplicationRecord end end - def commits - @commits ||= load_commits + def commits(limit: nil) + strong_memoize(:"commits_#{limit || 'all'}") do + load_commits(limit: limit) + end end def last_commit_sha - commit_shas.first + commit_shas(limit: 1).first end def first_commit @@ -247,8 +249,8 @@ class MergeRequestDiff < ApplicationRecord project.commit_by(oid: head_commit_sha) end - def commit_shas - merge_request_diff_commits.map(&:sha) + def commit_shas(limit: nil) + merge_request_diff_commits.limit(limit).pluck(:sha) end def commits_by_shas(shas) @@ -529,8 +531,9 @@ class MergeRequestDiff < ApplicationRecord end end - def load_commits - commits = merge_request_diff_commits.map { |commit| Commit.from_hash(commit.to_hash, project) } + def load_commits(limit: nil) + commits = merge_request_diff_commits.limit(limit) + .map { |commit| Commit.from_hash(commit.to_hash, project) } CommitCollection .new(merge_request.source_project, commits, merge_request.source_branch) diff --git a/app/models/milestone.rb b/app/models/milestone.rb index a9f4cdec901..d0be54eed02 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -60,6 +60,7 @@ class Milestone < ApplicationRecord validates :group, presence: true, unless: :project validates :project, presence: true, unless: :group + validates :title, presence: true validate :uniqueness_of_title, if: :title_changed? validate :milestone_type_check @@ -330,6 +331,6 @@ class Milestone < ApplicationRecord end def issues_finder_params - { project_id: project_id } + { project_id: project_id, group_id: group_id }.compact end end diff --git a/app/models/notification_reason.rb b/app/models/notification_reason.rb index 6856d397413..a7967239417 100644 --- a/app/models/notification_reason.rb +++ b/app/models/notification_reason.rb @@ -6,12 +6,14 @@ class NotificationReason OWN_ACTIVITY = 'own_activity' ASSIGNED = 'assigned' MENTIONED = 'mentioned' + SUBSCRIBED = 'subscribed' # Priority list for selecting which reason to return in the notification REASON_PRIORITY = [ OWN_ACTIVITY, ASSIGNED, - MENTIONED + MENTIONED, + SUBSCRIBED ].freeze # returns the priority of a reason as an integer diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 7903a2182dd..3869d86b667 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -24,6 +24,8 @@ class PagesDomain < ApplicationRecord validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } validate :validate_intermediates, if: ->(domain) { domain.certificate.present? && domain.certificate_changed? } + default_value_for(:auto_ssl_enabled, allow_nil: false) { ::Gitlab::LetsEncrypt.enabled? } + attr_encrypted :key, mode: :per_attribute_iv_and_salt, insecure_mode: true, diff --git a/app/models/project.rb b/app/models/project.rb index 74da042d5a5..f4aa336fbcd 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -76,6 +76,10 @@ class Project < ApplicationRecord delegate :no_import?, to: :import_state, allow_nil: true + # TODO: remove once GitLab 12.5 is released + # https://gitlab.com/gitlab-org/gitlab/issues/34638 + self.ignored_columns += %i[merge_requests_require_code_owner_approval] + default_value_for :archived, false default_value_for :resolve_outdated_diff_discussions, false default_value_for :container_registry_enabled, gitlab_config_features.container_registry @@ -87,6 +91,8 @@ class Project < ApplicationRecord default_value_for :wiki_enabled, gitlab_config_features.wiki default_value_for :snippets_enabled, gitlab_config_features.snippets default_value_for :only_allow_merge_if_all_discussions_are_resolved, false + default_value_for :remove_source_branch_after_merge, true + default_value_for(:ci_config_path) { Gitlab::CurrentSettings.default_ci_config_path } add_authentication_token_field :runners_token, encrypted: -> { Feature.enabled?(:projects_tokens_optional_encryption, default_enabled: true) ? :optional : :required } @@ -281,6 +287,7 @@ class Project < ApplicationRecord has_many :variables, class_name: 'Ci::Variable' has_many :triggers, class_name: 'Ci::Trigger' has_many :environments + has_many :environments_for_dashboard, -> { from(with_rank.unfoldered.available, :environments).where('rank <= 3') }, class_name: 'Environment' has_many :deployments has_many :pipeline_schedules, class_name: 'Ci::PipelineSchedule' has_many :project_deploy_tokens @@ -390,6 +397,7 @@ class Project < ApplicationRecord scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } scope :with_statistics, -> { includes(:statistics) } scope :with_shared_runners, -> { where(shared_runners_enabled: true) } + scope :with_container_registry, -> { where(container_registry_enabled: true) } scope :inside_path, ->(path) do # We need routes alias rs for JOIN so it does not conflict with # includes(:route) which we use in ProjectsFinder. @@ -456,13 +464,6 @@ class Project < ApplicationRecord # Used by Projects::CleanupService to hold a map of rewritten object IDs mount_uploader :bfg_object_map, AttachmentUploader - # Returns a project, if it is not about to be removed. - # - # id - The ID of the project to retrieve. - def self.find_without_deleted(id) - without_deleted.find_by_id(id) - end - def self.eager_load_namespace_and_owner includes(namespace: :owner) end @@ -656,6 +657,11 @@ class Project < ApplicationRecord end end + def preload_protected_branches + preloader = ActiveRecord::Associations::Preloader.new + preloader.preload(self, protected_branches: [:push_access_levels, :merge_access_levels]) + end + # returns all ancestor-groups upto but excluding the given namespace # when no namespace is given, all ancestors upto the top are returned def ancestors_upto(top = nil, hierarchy_order: nil) @@ -1906,7 +1912,7 @@ class Project < ApplicationRecord end def default_environment - production_first = "(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC" + production_first = Arel.sql("(CASE WHEN name = 'production' THEN 0 ELSE 1 END), id ASC") environments .with_state(:available) @@ -1961,27 +1967,6 @@ class Project < ApplicationRecord (auto_devops || build_auto_devops)&.predefined_variables end - def append_or_update_attribute(name, value) - if Project.reflect_on_association(name).try(:macro) == :has_many - # if this is 1-to-N relation, update the parent object - value.each do |item| - item.update!( - Project.reflect_on_association(name).foreign_key => id) - end - - # force to drop relation cache - public_send(name).reset # rubocop:disable GitlabSecurity/PublicSend - - # succeeded - true - else - # if this is another relation or attribute, update just object - update_attribute(name, value) - end - rescue ActiveRecord::RecordInvalid => e - raise e, "Failed to set #{name}: #{e.message}" - end - # Tries to set repository as read_only, checking for existing Git transfers in progress beforehand # # @return [Boolean] true when set to read_only or false when an existing git transfer is in progress diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index a495d34c07c..d089a004d3d 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true class ProjectCiCdSetting < ApplicationRecord + # TODO: remove once GitLab 12.7 is released + # https://gitlab.com/gitlab-org/gitlab/issues/36651 + self.ignored_columns += %i[merge_trains_enabled] belongs_to :project, inverse_of: :ci_cd_settings # The version of the schema that first introduced this model/table. diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb index a3793d9937b..46fe894cfc3 100644 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ b/app/models/project_services/chat_message/pipeline_message.rb @@ -75,11 +75,11 @@ module ChatMessage def activity { - title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{branch_link} by %{user_combined_name} %{humanized_status}") % + title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}") % { pipeline_link: pipeline_link, ref_type: ref_type, - branch_link: branch_link, + ref_link: ref_link, user_combined_name: user_combined_name, humanized_status: humanized_status }, @@ -123,7 +123,7 @@ module ChatMessage fields = [ { title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), - value: Slack::Notifier::LinkFormatter.format(ref_name_link), + value: Slack::Notifier::LinkFormatter.format(ref_link), short: true }, { @@ -141,12 +141,12 @@ module ChatMessage end def message - s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{branch_link} by %{user_combined_name} %{humanized_status} in %{duration}") % + s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}") % { project_link: project_link, pipeline_link: pipeline_link, ref_type: ref_type, - branch_link: branch_link, + ref_link: ref_link, user_combined_name: user_combined_name, humanized_status: humanized_status, duration: pretty_duration(duration) @@ -193,12 +193,16 @@ module ChatMessage end end - def branch_url - "#{project_url}/commits/#{ref}" + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/commits/#{ref}" + end end - def branch_link - "[#{ref}](#{branch_url})" + def ref_link + "[#{ref}](#{ref_url})" end def project_url @@ -266,14 +270,6 @@ module ChatMessage "[#{commit.title}](#{commit_url})" end - def commits_page_url - "#{project_url}/commits/#{ref}" - end - - def ref_name_link - "[#{ref}](#{commits_page_url})" - end - def author_url return unless user && committer diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb index 8163fca33a2..07622f570c2 100644 --- a/app/models/project_services/chat_message/push_message.rb +++ b/app/models/project_services/chat_message/push_message.rb @@ -82,16 +82,20 @@ module ChatMessage Gitlab::Git.blank_ref?(after) end - def branch_url - "#{project_url}/commits/#{ref}" + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/commits/#{ref}" + end end def compare_url "#{project_url}/compare/#{before}...#{after}" end - def branch_link - "[#{ref}](#{branch_url})" + def ref_link + "[#{ref}](#{ref_url})" end def project_link @@ -104,11 +108,11 @@ module ChatMessage def compose_action_details if new_branch? - ['pushed new', branch_link, "to #{project_link}"] + ['pushed new', ref_link, "to #{project_link}"] elsif removed_branch? ['removed', ref, "from #{project_link}"] else - ['pushed to', branch_link, "of #{project_link} (#{compare_link})"] + ['pushed to', ref_link, "of #{project_link} (#{compare_link})"] end end diff --git a/app/models/project_services/data_fields.rb b/app/models/project_services/data_fields.rb index cffb493d569..cf406a784ce 100644 --- a/app/models/project_services/data_fields.rb +++ b/app/models/project_services/data_fields.rb @@ -50,7 +50,7 @@ module DataFields end def data_fields_present? - data_fields.persisted? + data_fields.present? rescue NotImplementedError false end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 6eff2ea2e3a..a0273fe0e5a 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -7,8 +7,15 @@ class PrometheusService < MonitoringService prop_accessor :api_url boolean_accessor :manual_configuration + # We need to allow the self-monitoring project to connect to the internal + # Prometheus instance. + # Since the internal Prometheus instance is usually a localhost URL, we need + # to allow localhost URLs when the following conditions are true: + # 1. project is the self-monitoring project. + # 2. api_url is the internal Prometheus URL. with_options presence: true, if: :manual_configuration? do - validates :api_url, public_url: true + validates :api_url, public_url: true, unless: proc { |object| object.allow_local_api_url? } + validates :api_url, url: true, if: proc { |object| object.allow_local_api_url? } end before_save :synchronize_service_state @@ -82,12 +89,28 @@ class PrometheusService < MonitoringService project.clusters.enabled.any? { |cluster| cluster.application_prometheus_available? } end + def allow_local_api_url? + self_monitoring_project? && internal_prometheus_url? + end + private + def self_monitoring_project? + project && project.id == current_settings.instance_administration_project_id + end + + def internal_prometheus_url? + api_url.present? && api_url == ::Gitlab::Prometheus::Internal.uri + end + def should_return_client? api_url.present? && manual_configuration? && active? && valid? end + def current_settings + Gitlab::CurrentSettings.current_application_settings + end + def synchronize_service_state self.active = prometheus_available? || manual_configuration? diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb index b3585c4cf4c..e732c1bd86f 100644 --- a/app/models/project_snippet.rb +++ b/app/models/project_snippet.rb @@ -2,13 +2,6 @@ class ProjectSnippet < Snippet belongs_to :project - belongs_to :author, class_name: "User" validates :project, presence: true - - # Scopes - scope :fresh, -> { order("created_at DESC") } - - participant :author - participant :notes_with_associations end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index bb222ac7629..f02ccd9e55e 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -160,12 +160,6 @@ class ProjectWiki update_project_activity end - def page_formatted_data(page) - page_title, page_dir = page_title_and_dir(page.title) - - wiki.page_formatted_data(title: page_title, dir: page_dir, version: page.version) - end - def page_title_and_dir(title) return unless title diff --git a/app/models/prometheus_metric.rb b/app/models/prometheus_metric.rb index 08f4df7ea01..d0dc31476ff 100644 --- a/app/models/prometheus_metric.rb +++ b/app/models/prometheus_metric.rb @@ -14,7 +14,13 @@ class PrometheusMetric < ApplicationRecord validates :project, presence: true, unless: :common? validates :project, absence: true, if: :common? + scope :for_project, -> (project) { where(project: project) } + scope :for_group, -> (group) { where(group: group) } + scope :for_title, -> (title) { where(title: title) } + scope :for_y_label, -> (y_label) { where(y_label: y_label) } + scope :for_identifier, -> (identifier) { where(identifier: identifier) } scope :common, -> { where(common: true) } + scope :ordered, -> { reorder(created_at: :asc) } def priority group_details(group).fetch(:priority) diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 1857a59e01c..735e2bdea81 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -38,7 +38,7 @@ class ProtectedBranch < ApplicationRecord end def self.protected_refs(project) - project.protected_branches.select(:name) + project.protected_branches end def self.branch_requires_code_owner_approval?(project, branch_name) diff --git a/app/models/release.rb b/app/models/release.rb index 5a7bfe2d495..401e8359f47 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class Release < ApplicationRecord + include Presentable include CacheMarkdownField include Gitlab::Utils::StrongMemoize @@ -26,13 +27,21 @@ class Release < ApplicationRecord validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } scope :sorted, -> { order(released_at: :desc) } + scope :preloaded, -> { includes(project: :namespace) } scope :with_project_and_namespace, -> { includes(project: :namespace) } + scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } delegate :repository, to: :project after_commit :create_evidence!, on: :create after_commit :notify_new_release, on: :create + MAX_NUMBER_TO_DISPLAY = 3 + + def to_param + CGI.escape(tag) + end + def commit strong_memoize(:commit) do repository.commit(actual_sha) @@ -60,6 +69,10 @@ class Release < ApplicationRecord released_at.present? && released_at > Time.zone.now end + def name + self.read_attribute(:name) || tag + end + private def actual_sha diff --git a/app/models/releases/source.rb b/app/models/releases/source.rb index 4d3d54457af..2f00d25d768 100644 --- a/app/models/releases/source.rb +++ b/app/models/releases/source.rb @@ -6,11 +6,9 @@ module Releases attr_accessor :project, :tag_name, :format - FORMATS = %w(zip tar.gz tar.bz2 tar).freeze - class << self def all(project, tag_name) - Releases::Source::FORMATS.map do |format| + Gitlab::Workhorse::ARCHIVE_FORMATS.map do |format| Releases::Source.new(project: project, tag_name: tag_name, format: format) diff --git a/app/models/service.rb b/app/models/service.rb index 305cf7b78a2..6d5b974dd31 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -40,6 +40,7 @@ class Service < ApplicationRecord scope :external_wikis, -> { where(type: 'ExternalWikiService').active } scope :active, -> { where(active: true) } scope :without_defaults, -> { where(default: false) } + scope :by_type, -> (type) { where(type: type) } scope :push_hooks, -> { where(push_events: true, active: true) } scope :tag_push_hooks, -> { where(tag_push_events: true, active: true) } diff --git a/app/models/todo.rb b/app/models/todo.rb index 1927b54510e..f217c942e8e 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -55,7 +55,8 @@ class Todo < ApplicationRecord scope :done, -> { with_state(:done) } scope :for_action, -> (action) { where(action: action) } scope :for_author, -> (author) { where(author: author) } - scope :for_project, -> (project) { where(project: project) } + scope :for_project, -> (projects) { where(project: projects) } + scope :for_undeleted_projects, -> { joins(:project).merge(Project.without_deleted) } scope :for_group, -> (group) { where(group: group) } scope :for_type, -> (type) { where(target_type: type) } scope :for_target, -> (id) { where(target_id: id) } @@ -160,6 +161,10 @@ class Todo < ApplicationRecord action == ASSIGNED end + def done? + state == 'done' + end + def action_name ACTION_NAMES[action] end diff --git a/app/models/user.rb b/app/models/user.rb index eec8ad6edbb..d0e758b0055 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,9 +56,6 @@ class User < ApplicationRecord BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \ "administrator if you think this is an error." - # Removed in GitLab 12.3. Keep until after 2019-09-22. - self.ignored_columns += %i[support_bot] - MINIMUM_INACTIVE_DAYS = 180 # Override Devise::Models::Trackable#update_tracked_fields! @@ -243,6 +240,8 @@ class User < ApplicationRecord delegate :time_display_relative, :time_display_relative=, to: :user_preference delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference delegate :show_whitespace_in_diffs, :show_whitespace_in_diffs=, to: :user_preference + delegate :sourcegraph_enabled, :sourcegraph_enabled=, to: :user_preference + delegate :setup_for_company, :setup_for_company=, to: :user_preference accepts_nested_attributes_for :user_preference, update_only: true @@ -1423,14 +1422,13 @@ class User < ApplicationRecord # flow means we don't call that automatically (and can't conveniently do so). # # See: - # <https://github.com/plataformatec/devise/blob/v4.0.0/lib/devise/models/lockable.rb#L92> + # <https://github.com/plataformatec/devise/blob/v4.7.1/lib/devise/models/lockable.rb#L104> # # rubocop: disable CodeReuse/ServiceClass def increment_failed_attempts! return if ::Gitlab::Database.read_only? - self.failed_attempts ||= 0 - self.failed_attempts += 1 + increment_failed_attempts if attempts_exceeded? lock_access! unless access_locked? @@ -1458,7 +1456,7 @@ class User < ApplicationRecord # Does the user have access to all private groups & projects? # Overridden in EE to also check auditor? def full_private_access? - admin? + can?(:read_all_resources) end def update_two_factor_requirement diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 68241d2bd95..f9c562364cb 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -112,11 +112,6 @@ class WikiPage wiki.page_title_and_dir(slug)&.last.to_s end - # The processed/formatted content of this page. - def formatted_content - @attributes[:formatted_content] ||= @wiki.page_formatted_data(@page) - end - # The markup format for the page. def format @attributes[:format] || :markdown diff --git a/app/models/zoom_meeting.rb b/app/models/zoom_meeting.rb new file mode 100644 index 00000000000..a7ecd1e6a2c --- /dev/null +++ b/app/models/zoom_meeting.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ZoomMeeting < ApplicationRecord + belongs_to :project, optional: false + belongs_to :issue, optional: false + + validates :url, presence: true, length: { maximum: 255 }, zoom_url: true + validates :issue, same_project_association: true + + enum issue_status: { + added: 1, + removed: 2 + } + + scope :added_to_issue, -> { where(issue_status: :added) } + scope :removed_from_issue, -> { where(issue_status: :removed) } + scope :canonical, -> (issue) { where(issue: issue).added_to_issue } + + def self.canonical_meeting(issue) + canonical(issue)&.take + end + + def self.canonical_meeting_url(issue) + canonical_meeting(issue)&.url + end +end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 18c23cbd13a..8f5c6957a20 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -21,10 +21,6 @@ class BasePolicy < DeclarativePolicy::Base with_options scope: :user, score: 0 condition(:deactivated) { @user&.deactivated? } - desc "User has access to all private groups & projects" - with_options scope: :user, score: 0 - condition(:full_private_access) { @user&.full_private_access? } - with_options scope: :user, score: 0 condition(:external_user) { @user.nil? || @user.external? } @@ -40,10 +36,12 @@ class BasePolicy < DeclarativePolicy::Base ::Gitlab::ExternalAuthorization.perform_check? end - rule { external_authorization_enabled & ~full_private_access }.policy do + rule { external_authorization_enabled & ~can?(:read_all_resources) }.policy do prevent :read_cross_project end + rule { admin }.enable :read_all_resources + rule { default }.enable :read_cross_project end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 13e5b4ae41a..1cd400e4dfa 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -44,6 +44,7 @@ class GroupPolicy < BasePolicy rule { public_group }.policy do enable :read_group + enable :read_package end rule { logged_in_viewable }.enable :read_group @@ -70,7 +71,10 @@ class GroupPolicy < BasePolicy rule { has_access }.enable :read_namespace - rule { developer }.enable :admin_milestone + rule { developer }.policy do + enable :admin_milestone + enable :read_package + end rule { reporter }.policy do enable :read_container_image diff --git a/app/policies/personal_snippet_policy.rb b/app/policies/personal_snippet_policy.rb index 40dd49b4afd..91a8f3a7133 100644 --- a/app/policies/personal_snippet_policy.rb +++ b/app/policies/personal_snippet_policy.rb @@ -10,7 +10,7 @@ class PersonalSnippetPolicy < BasePolicy enable :create_note end - rule { is_author }.policy do + rule { is_author | admin }.policy do enable :read_personal_snippet enable :update_personal_snippet enable :destroy_personal_snippet @@ -30,5 +30,5 @@ class PersonalSnippetPolicy < BasePolicy rule { can?(:create_note) }.enable :award_emoji - rule { full_private_access }.enable :read_personal_snippet + rule { can?(:read_all_resources) }.enable :read_personal_snippet end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index ea2be37d7e6..ff70c6e6aeb 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -117,6 +117,10 @@ class ProjectPolicy < BasePolicy !@subject.builds_enabled? end + condition(:user_confirmed?) do + @user && @user.confirmed? + end + features = %w[ merge_requests issues @@ -249,10 +253,7 @@ class ProjectPolicy < BasePolicy enable :update_commit_status enable :create_build enable :update_build - enable :create_pipeline - enable :update_pipeline enable :read_pipeline_schedule - enable :create_pipeline_schedule enable :create_merge_request_from enable :create_wiki enable :push_code @@ -267,6 +268,12 @@ class ProjectPolicy < BasePolicy enable :update_release end + rule { can?(:developer_access) & user_confirmed? }.policy do + enable :create_pipeline + enable :update_pipeline + enable :create_pipeline_schedule + end + rule { can?(:maintainer_access) }.policy do enable :admin_board enable :push_to_delete_protected_branch @@ -418,7 +425,7 @@ class ProjectPolicy < BasePolicy # These rules are included to allow maintainers of projects to push to certain # to run pipelines for the branches they have access to. - rule { can?(:public_access) & has_merge_requests_allowing_pushes }.policy do + rule { can?(:public_access) & has_merge_requests_allowing_pushes & user_confirmed? }.policy do enable :create_build enable :create_pipeline end diff --git a/app/policies/project_snippet_policy.rb b/app/policies/project_snippet_policy.rb index 2a3e4ca174b..d9d09eb04cd 100644 --- a/app/policies/project_snippet_policy.rb +++ b/app/policies/project_snippet_policy.rb @@ -28,7 +28,7 @@ class ProjectSnippetPolicy < BasePolicy all?(private_snippet | (internal_snippet & external_user), ~project.guest, ~is_author, - ~full_private_access) + ~can?(:read_all_resources)) end.prevent :read_project_snippet rule { internal_snippet & ~is_author & ~admin }.policy do diff --git a/app/policies/todo_policy.rb b/app/policies/todo_policy.rb index f8644217f04..d01a046c343 100644 --- a/app/policies/todo_policy.rb +++ b/app/policies/todo_policy.rb @@ -7,4 +7,5 @@ class TodoPolicy < BasePolicy end rule { own_todo }.enable :read_todo + rule { own_todo }.enable :update_todo end diff --git a/app/presenters/clusterable_presenter.rb b/app/presenters/clusterable_presenter.rb index 34dffbf40fd..2306f55f1f4 100644 --- a/app/presenters/clusterable_presenter.rb +++ b/app/presenters/clusterable_presenter.rb @@ -29,6 +29,18 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated new_polymorphic_path([clusterable, :cluster], options) end + def aws_api_proxy_path(resource) + polymorphic_path([clusterable, :clusters], action: :aws_proxy, resource: resource) + end + + def authorize_aws_role_path + polymorphic_path([clusterable, :clusters], action: :authorize_aws_role) + end + + def revoke_aws_role_path + polymorphic_path([clusterable, :clusters], action: :revoke_aws_role) + end + def create_user_clusters_path polymorphic_path([clusterable, :clusters], action: :create_user) end @@ -37,6 +49,10 @@ class ClusterablePresenter < Gitlab::View::Presenter::Delegated polymorphic_path([clusterable, :clusters], action: :create_gcp) end + def create_aws_clusters_path + polymorphic_path([clusterable, :clusters], action: :create_aws) + end + def cluster_status_cluster_path(cluster, params = {}) raise NotImplementedError end diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index f1182ec26f4..66ae840a619 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -11,7 +11,9 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated stale_schedule: 'Delayed job could not be executed by some reason, please try again', job_execution_timeout: 'The script exceeded the maximum execution time set for the job', archived_failure: 'The job is archived and cannot be run', - unmet_prerequisites: 'The job failed to complete prerequisite tasks' + unmet_prerequisites: 'The job failed to complete prerequisite tasks', + scheduler_failure: 'The scheduler failed to assign job to the runner, please try again or contact system administrator', + data_integrity_failure: 'There has been a structural integrity problem detected, please contact system administrator' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES @@ -33,6 +35,6 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated end def unrecoverable? - script_failure? || missing_dependency_failure? || archived_failure? + script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure? end end diff --git a/app/presenters/instance_clusterable_presenter.rb b/app/presenters/instance_clusterable_presenter.rb index 908cd17678d..c6572e8ce71 100644 --- a/app/presenters/instance_clusterable_presenter.rb +++ b/app/presenters/instance_clusterable_presenter.rb @@ -52,6 +52,26 @@ class InstanceClusterablePresenter < ClusterablePresenter create_gcp_admin_clusters_path end + override :create_aws_clusters_path + def create_aws_clusters_path + create_aws_admin_clusters_path + end + + override :authorize_aws_role_path + def authorize_aws_role_path + authorize_aws_role_admin_clusters_path + end + + override :revoke_aws_role_path + def revoke_aws_role_path + revoke_aws_role_admin_clusters_path + end + + override :aws_api_proxy_path + def aws_api_proxy_path(resource) + aws_proxy_admin_clusters_path(resource: resource) + end + override :empty_state_help_text def empty_state_help_text s_('ClusterIntegration|Adding an integration will share the cluster across all projects.') diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 6d370f6241c..81018398d5d 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -21,7 +21,6 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def statistics_anchors(show_auto_devops_callout:) [ - license_anchor_data, commits_anchor_data, branches_anchor_data, tags_anchor_data, @@ -32,6 +31,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def statistics_buttons(show_auto_devops_callout:) [ readme_anchor_data, + license_anchor_data, changelog_anchor_data, contribution_guide_anchor_data, autodevops_anchor_data(show_auto_devops_callout: show_auto_devops_callout), @@ -41,15 +41,14 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated end def empty_repo_statistics_anchors - [ - license_anchor_data - ].compact.select { |item| item.is_link } + [] end def empty_repo_statistics_buttons [ new_file_anchor_data, readme_anchor_data, + license_anchor_data, changelog_anchor_data, contribution_guide_anchor_data, gitlab_ci_anchor_data @@ -227,17 +226,18 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated icon = statistic_icon('scale') if repository.license_blob.present? - AnchorData.new(true, - icon + content_tag(:strong, license_short_name, class: 'project-stat-value'), - license_path) + AnchorData.new(false, + icon + content_tag(:span, license_short_name, class: 'project-stat-value'), + license_path, + 'default') else if current_user && can_current_user_push_to_default_branch? - AnchorData.new(true, - content_tag(:span, icon + _('Add license'), class: 'add-license-link d-flex'), + AnchorData.new(false, + content_tag(:span, statistic_icon + _('Add LICENSE'), class: 'add-license-link d-flex'), add_license_path) else - AnchorData.new(true, - icon + content_tag(:strong, _('No license. All rights reserved'), class: 'project-stat-value'), + AnchorData.new(false, + icon + content_tag(:span, _('No license. All rights reserved'), class: 'project-stat-value'), nil) end end diff --git a/app/presenters/release_presenter.rb b/app/presenters/release_presenter.rb new file mode 100644 index 00000000000..42463d6dbda --- /dev/null +++ b/app/presenters/release_presenter.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class ReleasePresenter < Gitlab::View::Presenter::Delegated + include ActionView::Helpers::UrlHelper + + presents :release + + delegate :project, :tag, to: :release + + def commit_path + return unless release.commit && can_download_code? + + project_commit_path(project, release.commit.id) + end + + def tag_path + return unless can_download_code? + + project_tag_path(project, release.tag) + end + + def merge_requests_url + return unless release_mr_issue_urls_available? + + project_merge_requests_url(project, params_for_issues_and_mrs) + end + + def issues_url + return unless release_mr_issue_urls_available? + + project_issues_url(project, params_for_issues_and_mrs) + end + + def edit_url + return unless release_edit_page_available? + + edit_project_release_url(project, release) + end + + private + + def can_download_code? + can?(current_user, :download_code, project) + end + + def params_for_issues_and_mrs + { scope: 'all', state: 'opened', release_tag: release.tag } + end + + def release_mr_issue_urls_available? + ::Feature.enabled?(:release_mr_issue_urls, project) + end + + def release_edit_page_available? + ::Feature.enabled?(:release_edit_page, project, default_enabled: true) + end +end diff --git a/app/presenters/todo_presenter.rb b/app/presenters/todo_presenter.rb index b57fc712c5a..291be7848e2 100644 --- a/app/presenters/todo_presenter.rb +++ b/app/presenters/todo_presenter.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true class TodoPresenter < Gitlab::View::Presenter::Delegated - include GlobalID::Identification - presents :todo end diff --git a/app/serializers/cluster_application_entity.rb b/app/serializers/cluster_application_entity.rb index 2a916b13f52..218bdd21e37 100644 --- a/app/serializers/cluster_application_entity.rb +++ b/app/serializers/cluster_application_entity.rb @@ -8,7 +8,9 @@ class ClusterApplicationEntity < Grape::Entity expose :external_ip, if: -> (e, _) { e.respond_to?(:external_ip) } expose :external_hostname, if: -> (e, _) { e.respond_to?(:external_hostname) } expose :hostname, if: -> (e, _) { e.respond_to?(:hostname) } + expose :kibana_hostname, if: -> (e, _) { e.respond_to?(:kibana_hostname) } expose :email, if: -> (e, _) { e.respond_to?(:email) } + expose :stack, if: -> (e, _) { e.respond_to?(:stack) } expose :update_available?, as: :update_available, if: -> (e, _) { e.respond_to?(:update_available?) } expose :can_uninstall?, as: :can_uninstall end diff --git a/app/serializers/container_repositories_serializer.rb b/app/serializers/container_repositories_serializer.rb index e1ce3c7b3ae..bc35a67ff24 100644 --- a/app/serializers/container_repositories_serializer.rb +++ b/app/serializers/container_repositories_serializer.rb @@ -2,4 +2,8 @@ class ContainerRepositoriesSerializer < BaseSerializer entity ContainerRepositoryEntity + + def represent_read_only(resource) + represent(resource, except: [:destroy_path]) + end end diff --git a/app/serializers/diff_file_base_entity.rb b/app/serializers/diff_file_base_entity.rb index ee68b4b98e0..302fe3d7c67 100644 --- a/app/serializers/diff_file_base_entity.rb +++ b/app/serializers/diff_file_base_entity.rb @@ -89,6 +89,14 @@ class DiffFileBaseEntity < Grape::Entity expose :viewer, using: DiffViewerEntity + expose :old_size do |diff_file| + diff_file.old_blob&.raw_size + end + + expose :new_size do |diff_file| + diff_file.new_blob&.raw_size + end + private def memoized_submodule_links(diff_file, options) diff --git a/app/serializers/diff_file_entity.rb b/app/serializers/diff_file_entity.rb index 2a5121a2266..af7d1172f17 100644 --- a/app/serializers/diff_file_entity.rb +++ b/app/serializers/diff_file_entity.rb @@ -53,7 +53,7 @@ class DiffFileEntity < DiffFileBaseEntity end # Used for inline diffs - expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, _) { diff_file.text? } do |diff_file| + expose :highlighted_diff_lines, using: DiffLineEntity, if: -> (diff_file, options) { inline_diff_view?(options) && diff_file.text? } do |diff_file| diff_file.diff_lines_for_serializer end @@ -62,5 +62,21 @@ class DiffFileEntity < DiffFileBaseEntity end # Used for parallel diffs - expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, _) { diff_file.text? } + expose :parallel_diff_lines, using: DiffLineParallelEntity, if: -> (diff_file, options) { parallel_diff_view?(options) && diff_file.text? } + + private + + def parallel_diff_view?(options) + return true unless Feature.enabled?(:single_mr_diff_view) + + # If we're not rendering inline, we must be rendering parallel + !inline_diff_view?(options) + end + + def inline_diff_view?(options) + return true unless Feature.enabled?(:single_mr_diff_view) + + # If nothing is present, inline will be the default. + options.fetch(:diff_view, :inline).to_sym == :inline + end end diff --git a/app/serializers/error_tracking/detailed_error_entity.rb b/app/serializers/error_tracking/detailed_error_entity.rb new file mode 100644 index 00000000000..8f08f84aa41 --- /dev/null +++ b/app/serializers/error_tracking/detailed_error_entity.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ErrorTracking + class DetailedErrorEntity < Grape::Entity + expose :count, + :culprit, + :external_base_url, + :external_url, + :first_release_last_commit, + :first_release_short_version, + :first_seen, + :frequency, + :id, + :last_release_last_commit, + :last_release_short_version, + :last_seen, + :message, + :project_id, + :project_name, + :project_slug, + :short_id, + :status, + :title, + :type, + :user_count + end +end diff --git a/app/serializers/error_tracking/detailed_error_serializer.rb b/app/serializers/error_tracking/detailed_error_serializer.rb new file mode 100644 index 00000000000..201da16a1ae --- /dev/null +++ b/app/serializers/error_tracking/detailed_error_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class DetailedErrorSerializer < BaseSerializer + entity DetailedErrorEntity + end +end diff --git a/app/serializers/error_tracking/error_event_entity.rb b/app/serializers/error_tracking/error_event_entity.rb new file mode 100644 index 00000000000..6cf0e6e3ae2 --- /dev/null +++ b/app/serializers/error_tracking/error_event_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ErrorEventEntity < Grape::Entity + expose :issue_id, :date_received, :stack_trace_entries + end +end diff --git a/app/serializers/error_tracking/error_event_serializer.rb b/app/serializers/error_tracking/error_event_serializer.rb new file mode 100644 index 00000000000..bc4eae16368 --- /dev/null +++ b/app/serializers/error_tracking/error_event_serializer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ErrorTracking + class ErrorEventSerializer < BaseSerializer + entity ErrorEventEntity + end +end diff --git a/app/serializers/issuable_sidebar_extras_entity.rb b/app/serializers/issuable_sidebar_extras_entity.rb index fb35b7522c5..0e1fcc58d7a 100644 --- a/app/serializers/issuable_sidebar_extras_entity.rb +++ b/app/serializers/issuable_sidebar_extras_entity.rb @@ -3,11 +3,20 @@ class IssuableSidebarExtrasEntity < Grape::Entity include RequestAwareEntity include TimeTrackableEntity + include NotificationsHelper expose :participants, using: ::API::Entities::UserBasic do |issuable| issuable.participants(request.current_user) end + expose :project_emails_disabled do |issuable| + issuable.project.emails_disabled? + end + + expose :subscribe_disabled_description do |issuable| + notification_description(:owner_disabled) + end + expose :subscribed do |issuable| issuable.subscribed?(request.current_user, issuable.project) end diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb index b8f799a7456..13897279815 100644 --- a/app/serializers/issue_board_entity.rb +++ b/app/serializers/issue_board_entity.rb @@ -2,7 +2,6 @@ class IssueBoardEntity < Grape::Entity include RequestAwareEntity - include TimeTrackableEntity expose :id expose :iid diff --git a/app/serializers/job_artifact_report_entity.rb b/app/serializers/job_artifact_report_entity.rb index 4280351a6b0..bdab8f64785 100644 --- a/app/serializers/job_artifact_report_entity.rb +++ b/app/serializers/job_artifact_report_entity.rb @@ -8,6 +8,6 @@ class JobArtifactReportEntity < Grape::Entity expose :size expose :download_path do |artifact| - download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_format) + download_project_job_artifacts_path(artifact.job.project, artifact.job, file_type: artifact.file_type) end end diff --git a/app/serializers/merge_request_diff_entity.rb b/app/serializers/merge_request_diff_entity.rb index 7e3053e5881..5c79b165ee9 100644 --- a/app/serializers/merge_request_diff_entity.rb +++ b/app/serializers/merge_request_diff_entity.rb @@ -21,6 +21,8 @@ class MergeRequestDiffEntity < Grape::Entity expose :latest?, as: :latest expose :short_commit_sha do |merge_request_diff| + next unless merge_request_diff.head_commit_sha + short_sha(merge_request_diff.head_commit_sha) end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index 854349e8507..2a61187a856 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -65,6 +65,12 @@ class MergeRequestPollWidgetEntity < IssuableEntity 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) + end + end + expose :create_issue_to_resolve_discussions_path do |merge_request| presenter(merge_request).create_issue_to_resolve_discussions_path end diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb index 1d3b59eb1b7..c49dec2a93c 100644 --- a/app/serializers/note_entity.rb +++ b/app/serializers/note_entity.rb @@ -79,3 +79,5 @@ class NoteEntity < API::Entities::Note request.current_user end end + +NoteEntity.prepend_if_ee('EE::NoteEntity') diff --git a/app/serializers/projects/serverless/service_entity.rb b/app/serializers/projects/serverless/service_entity.rb index a1e0bf02d11..10360e575bb 100644 --- a/app/serializers/projects/serverless/service_entity.rb +++ b/app/serializers/projects/serverless/service_entity.rb @@ -44,28 +44,52 @@ module Projects end expose :url do |service| - service.dig('status', 'url') || "http://#{service.dig('status', 'domain')}" + knative_06_07_url(service) || knative_05_url(service) end expose :description do |service| + knative_07_description(service) || knative_05_06_description(service) + end + + expose :image do |service| service.dig( 'spec', 'runLatest', 'configuration', - 'revisionTemplate', + 'build', + 'template', + 'name') + end + + private + + def knative_07_description(service) + service.dig( + 'spec', + 'template', 'metadata', 'annotations', - 'Description') + 'Description' + ) end - expose :image do |service| + def knative_05_url(service) + "http://#{service.dig('status', 'domain')}" + end + + def knative_06_07_url(service) + service.dig('status', 'url') + end + + def knative_05_06_description(service) service.dig( 'spec', 'runLatest', 'configuration', - 'build', - 'template', - 'name') + 'revisionTemplate', + 'metadata', + 'annotations', + 'Description') end end end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index c39edd5c114..bc0b968f516 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -50,16 +50,24 @@ class BaseService private - def error(message, http_status = nil) + # Return a Hash with an `error` status + # + # message - Error message to include in the Hash + # http_status - Optional HTTP status code override (default: nil) + # pass_back - Additional attributes to be included in the resulting Hash + def error(message, http_status = nil, pass_back: {}) result = { message: message, status: :error - } + }.reverse_merge(pass_back) result[:http_status] = http_status if http_status result end + # Return a Hash with a `success` status + # + # pass_back - Additional attributes to be included in the resulting Hash def success(pass_back = {}) pass_back[:status] = :success pass_back diff --git a/app/services/ci/compare_reports_base_service.rb b/app/services/ci/compare_reports_base_service.rb index 5b76e1824e4..83ba70e8437 100644 --- a/app/services/ci/compare_reports_base_service.rb +++ b/app/services/ci/compare_reports_base_service.rb @@ -1,6 +1,11 @@ # frozen_string_literal: true module Ci + # TODO: when using this class with exposed artifacts we see that there are + # 2 responsibilities: + # 1. reactive caching interface (same in all cases) + # 2. data generator (report comparison in most of the case but not always) + # issue: https://gitlab.com/gitlab-org/gitlab/issues/34224 class CompareReportsBaseService < ::BaseService def execute(base_pipeline, head_pipeline) comparer = comparer_class.new(get_report(base_pipeline), get_report(head_pipeline)) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index eb4176035d3..5778a48bce6 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -7,11 +7,14 @@ module Ci CreateError = Class.new(StandardError) SEQUENCE = [Gitlab::Ci::Pipeline::Chain::Build, - Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Validate::Abilities, Gitlab::Ci::Pipeline::Chain::Validate::Repository, - Gitlab::Ci::Pipeline::Chain::Validate::Config, + Gitlab::Ci::Pipeline::Chain::Config::Content, + Gitlab::Ci::Pipeline::Chain::Config::Process, + Gitlab::Ci::Pipeline::Chain::RemoveUnwantedChatJobs, Gitlab::Ci::Pipeline::Chain::Skip, + Gitlab::Ci::Pipeline::Chain::EvaluateWorkflowRules, + Gitlab::Ci::Pipeline::Chain::Seed, Gitlab::Ci::Pipeline::Chain::Limit::Size, Gitlab::Ci::Pipeline::Chain::Populate, Gitlab::Ci::Pipeline::Chain::Create, diff --git a/app/services/ci/find_exposed_artifacts_service.rb b/app/services/ci/find_exposed_artifacts_service.rb new file mode 100644 index 00000000000..5c75af294bf --- /dev/null +++ b/app/services/ci/find_exposed_artifacts_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Ci + # This class loops through all builds with exposed artifacts and returns + # basic information about exposed artifacts for given jobs for the frontend + # to display them as custom links in the merge request. + # + # This service must be used with care. + # Looking for exposed artifacts is very slow and should be done asynchronously. + class FindExposedArtifactsService < ::BaseService + include Gitlab::Routing + + MAX_EXPOSED_ARTIFACTS = 10 + + def for_pipeline(pipeline, limit: MAX_EXPOSED_ARTIFACTS) + results = [] + + pipeline.builds.latest.with_exposed_artifacts.find_each do |job| + if job_exposed_artifacts = for_job(job) + results << job_exposed_artifacts + end + + break if results.size >= limit + end + + results + end + + def for_job(job) + return unless job.has_exposed_artifacts? + + metadata_entries = first_2_metadata_entries_for_artifacts_paths(job) + return if metadata_entries.empty? + + { + text: job.artifacts_expose_as, + url: path_for_entries(metadata_entries, job), + job_path: project_job_path(project, job), + job_name: job.name + } + end + + private + + # we don't need to fetch all artifacts entries for a job because + # it could contain many. We only need to know whether it has 1 or more + # artifacts, so fetching the first 2 would be sufficient. + def first_2_metadata_entries_for_artifacts_paths(job) + job.artifacts_paths + .lazy + .map { |path| job.artifacts_metadata_entry(path, recursive: true) } + .select { |entry| entry.exists? } + .first(2) + end + + def path_for_entries(entries, job) + return if entries.empty? + + if single_artifact?(entries) + file_project_job_artifacts_path(project, job, entries.first.path) + else + browse_project_job_artifacts_path(project, job) + end + end + + def single_artifact?(entries) + entries.size == 1 && entries.first.file? + end + end +end diff --git a/app/services/ci/generate_exposed_artifacts_report_service.rb b/app/services/ci/generate_exposed_artifacts_report_service.rb new file mode 100644 index 00000000000..b9bf580bcbc --- /dev/null +++ b/app/services/ci/generate_exposed_artifacts_report_service.rb @@ -0,0 +1,30 @@ +# 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 GenerateExposedArtifactsReportService < CompareReportsBaseService + def execute(base_pipeline, head_pipeline) + data = FindExposedArtifactsService.new(project, current_user).for_pipeline(head_pipeline) + { + status: :parsed, + key: key(base_pipeline, head_pipeline), + data: data + } + rescue => e + Gitlab::Sentry.track_acceptable_exception(e, extra: { project_id: project.id }) + { + status: :error, + key: key(base_pipeline, head_pipeline), + status_reason: _('An error occurred while fetching exposed artifacts.') + } + 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/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index d8f32ff88ce..30e2a66e04a 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -42,26 +42,16 @@ module Ci end builds.each do |build| - next unless runner.can_pick?(build) - - begin - # In case when 2 runners try to assign the same build, second runner will be declined - # with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method. - if assign_runner!(build, params) - register_success(build) - - return Result.new(build, true) - end - rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError - # We are looping to find another build that is not conflicting - # It also indicates that this build can be picked and passed to runner. - # If we don't do it, basically a bunch of runners would be competing for a build - # and thus we will generate a lot of 409. This will increase - # the number of generated requests, also will reduce significantly - # how many builds can be picked by runner in a unit of time. - # In case we hit the concurrency-access lock, - # we still have to return 409 in the end, - # to make sure that this is properly handled by runner. + result = process_build(build, params) + next unless result + + if result.valid? + register_success(result.build) + + return result + else + # The usage of valid: is described in + # handling of ActiveRecord::StaleObjectError valid = false end end @@ -73,6 +63,35 @@ module Ci private + def process_build(build, params) + return unless runner.can_pick?(build) + + # In case when 2 runners try to assign the same build, second runner will be declined + # with StateMachines::InvalidTransition or StaleObjectError when doing run! or save method. + if assign_runner!(build, params) + Result.new(build, true) + end + rescue StateMachines::InvalidTransition, ActiveRecord::StaleObjectError + # We are looping to find another build that is not conflicting + # It also indicates that this build can be picked and passed to runner. + # If we don't do it, basically a bunch of runners would be competing for a build + # and thus we will generate a lot of 409. This will increase + # the number of generated requests, also will reduce significantly + # how many builds can be picked by runner in a unit of time. + # In case we hit the concurrency-access lock, + # we still have to return 409 in the end, + # to make sure that this is properly handled by runner. + Result.new(nil, false) + rescue => ex + raise ex unless Feature.enabled?(:ci_doom_build, default_enabled: true) + + scheduler_failure!(build) + track_exception_for_build(ex, build) + + # skip, and move to next one + nil + end + def assign_runner!(build, params) build.runner_id = runner.id build.runner_session_attributes = params[:session] if params[:session].present? @@ -96,6 +115,28 @@ module Ci true end + def scheduler_failure!(build) + Gitlab::OptimisticLocking.retry_lock(build, 3) do |subject| + subject.drop!(:scheduler_failure) + end + rescue => ex + build.doom! + + # This requires extra exception, otherwise we would loose information + # why we cannot perform `scheduler_failure` + track_exception_for_build(ex, build) + end + + def track_exception_for_build(ex, build) + Gitlab::Sentry.track_acceptable_exception(ex, extra: { + build_id: build.id, + build_name: build.name, + build_stage: build.stage, + pipeline_id: build.pipeline_id, + project_id: build.project_id + }) + end + # rubocop: disable CodeReuse/ActiveRecord def builds_for_shared_runner new_builds. @@ -108,7 +149,7 @@ module Ci # this returns builds that are ordered by number of running builds # we prefer projects that don't use shared runners at all joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.project_id=project_builds.project_id") - .order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') + .order(Arel.sql('COALESCE(project_builds.running_builds, 0) ASC'), 'ci_builds.id ASC') end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/clusters/applications/base_service.rb b/app/services/clusters/applications/base_service.rb index 67fb3ac8355..c9f7917938f 100644 --- a/app/services/clusters/applications/base_service.rb +++ b/app/services/clusters/applications/base_service.rb @@ -19,10 +19,18 @@ module Clusters application.hostname = params[:hostname] end + if application.has_attribute?(:kibana_hostname) + application.kibana_hostname = params[:kibana_hostname] + end + if application.has_attribute?(:email) application.email = params[:email] end + if application.has_attribute?(:stack) + application.stack = params[:stack] + end + if application.respond_to?(:oauth_application) application.oauth_application = create_oauth_application(application, request) end @@ -60,19 +68,13 @@ module Clusters end def invalid_application? - unknown_application? || (!cluster.project_type? && project_only_application?) + unknown_application? || (application_name == Applications::ElasticStack.application_name && !Feature.enabled?(:enable_cluster_application_elastic_stack)) || (application_name == Applications::Crossplane.application_name && !Feature.enabled?(:enable_cluster_application_crossplane)) end def unknown_application? Clusters::Cluster::APPLICATIONS.keys.exclude?(application_name) end - # These applications will need extra configuration to enable them to work - # with groups of projects - def project_only_application? - Clusters::Cluster::PROJECT_ONLY_APPLICATIONS.include?(application_name) - end - def application_name params[:application] end diff --git a/app/services/clusters/aws/fetch_credentials_service.rb b/app/services/clusters/aws/fetch_credentials_service.rb new file mode 100644 index 00000000000..2724d4b657b --- /dev/null +++ b/app/services/clusters/aws/fetch_credentials_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class FetchCredentialsService + attr_reader :provision_role + + MissingRoleError = Class.new(StandardError) + + def initialize(provision_role, region:, provider: nil) + @provision_role = provision_role + @region = region + @provider = provider + end + + def execute + raise MissingRoleError.new('AWS provisioning role not configured') unless provision_role.present? + + ::Aws::AssumeRoleCredentials.new( + client: client, + role_arn: provision_role.role_arn, + role_session_name: session_name, + external_id: provision_role.role_external_id + ).credentials + end + + private + + attr_reader :provider, :region + + def client + ::Aws::STS::Client.new(credentials: gitlab_credentials, region: region) + end + + def gitlab_credentials + ::Aws::Credentials.new(access_key_id, secret_access_key) + end + + def access_key_id + Gitlab::CurrentSettings.eks_access_key_id + end + + def secret_access_key + Gitlab::CurrentSettings.eks_secret_access_key + end + + def session_name + if provider.present? + "gitlab-eks-cluster-#{provider.cluster_id}-user-#{provision_role.user_id}" + else + "gitlab-eks-autofill-user-#{provision_role.user_id}" + end + end + end + end +end diff --git a/app/services/clusters/aws/finalize_creation_service.rb b/app/services/clusters/aws/finalize_creation_service.rb new file mode 100644 index 00000000000..54f07e1d44c --- /dev/null +++ b/app/services/clusters/aws/finalize_creation_service.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class FinalizeCreationService + include Gitlab::Utils::StrongMemoize + + attr_reader :provider + + delegate :cluster, to: :provider + + def execute(provider) + @provider = provider + + configure_provider + create_gitlab_service_account! + configure_platform_kubernetes + configure_node_authentication! + + cluster.save! + rescue ::Aws::CloudFormation::Errors::ServiceError => e + log_service_error(e.class.name, provider.id, e.message) + provider.make_errored!(s_('ClusterIntegration|Failed to fetch CloudFormation stack: %{message}') % { message: e.message }) + rescue Kubeclient::HttpError => e + log_service_error(e.class.name, provider.id, e.message) + provider.make_errored!(s_('ClusterIntegration|Failed to run Kubeclient: %{message}') % { message: e.message }) + rescue ActiveRecord::RecordInvalid => e + log_service_error(e.class.name, provider.id, e.message) + provider.make_errored!(s_('ClusterIntegration|Failed to configure EKS provider: %{message}') % { message: e.message }) + end + + private + + def create_gitlab_service_account! + Clusters::Kubernetes::CreateOrUpdateServiceAccountService.gitlab_creator( + kube_client, + rbac: true + ).execute + end + + def configure_provider + provider.status_event = :make_created + end + + def configure_platform_kubernetes + cluster.build_platform_kubernetes( + api_url: cluster_endpoint, + ca_cert: cluster_certificate, + token: request_kubernetes_token) + end + + def request_kubernetes_token + Clusters::Kubernetes::FetchKubernetesTokenService.new( + kube_client, + Clusters::Kubernetes::GITLAB_ADMIN_TOKEN_NAME, + Clusters::Kubernetes::GITLAB_SERVICE_ACCOUNT_NAMESPACE + ).execute + end + + def kube_client + @kube_client ||= build_kube_client!( + cluster_endpoint, + cluster_certificate + ) + end + + def build_kube_client!(api_url, ca_pem) + raise "Incomplete settings" unless api_url + + Gitlab::Kubernetes::KubeClient.new( + api_url, + auth_options: kubeclient_auth_options, + ssl_options: kubeclient_ssl_options(ca_pem), + http_proxy_uri: ENV['http_proxy'] + ) + end + + def kubeclient_auth_options + { bearer_token: Kubeclient::AmazonEksCredentials.token(provider.credentials, cluster.name) } + end + + def kubeclient_ssl_options(ca_pem) + opts = { verify_ssl: OpenSSL::SSL::VERIFY_PEER } + + if ca_pem.present? + opts[:cert_store] = OpenSSL::X509::Store.new + opts[:cert_store].add_cert(OpenSSL::X509::Certificate.new(ca_pem)) + end + + opts + end + + def cluster_stack + @cluster_stack ||= provider.api_client.describe_stacks(stack_name: provider.cluster.name).stacks.first + end + + def stack_output_value(key) + cluster_stack.outputs.detect { |output| output.output_key == key }.output_value + end + + def node_instance_role_arn + stack_output_value('NodeInstanceRole') + end + + def cluster_endpoint + strong_memoize(:cluster_endpoint) do + stack_output_value('ClusterEndpoint') + end + end + + def cluster_certificate + strong_memoize(:cluster_certificate) do + Base64.decode64(stack_output_value('ClusterCertificate')) + end + end + + def configure_node_authentication! + kube_client.create_config_map(node_authentication_config) + end + + def node_authentication_config + Gitlab::Kubernetes::ConfigMaps::AwsNodeAuth.new(node_instance_role_arn).generate + end + + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def log_service_error(exception, provider_id, message) + logger.error( + exception: exception.class.name, + service: self.class.name, + provider_id: provider_id, + message: message + ) + end + end + end +end diff --git a/app/services/clusters/aws/provision_service.rb b/app/services/clusters/aws/provision_service.rb new file mode 100644 index 00000000000..35fe8433b4d --- /dev/null +++ b/app/services/clusters/aws/provision_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class ProvisionService + attr_reader :provider + + def execute(provider) + @provider = provider + + configure_provider_credentials + provision_cluster + + if provider.make_creating + WaitForClusterCreationWorker.perform_in( + Clusters::Aws::VerifyProvisionStatusService::INITIAL_INTERVAL, + provider.cluster_id + ) + else + provider.make_errored!("Failed to update provider record; #{provider.errors.full_messages}") + end + rescue Clusters::Aws::FetchCredentialsService::MissingRoleError + provider.make_errored!('Amazon role is not configured') + rescue ::Aws::Errors::MissingCredentialsError + provider.make_errored!('Amazon credentials are not configured') + rescue ::Aws::STS::Errors::ServiceError => e + provider.make_errored!("Amazon authentication failed; #{e.message}") + rescue ::Aws::CloudFormation::Errors::ServiceError => e + provider.make_errored!("Amazon CloudFormation request failed; #{e.message}") + end + + private + + def provision_role + provider.created_by_user&.aws_role + end + + def credentials + @credentials ||= Clusters::Aws::FetchCredentialsService.new( + provision_role, + provider: provider, + region: provider.region + ).execute + end + + def configure_provider_credentials + provider.update!( + access_key_id: credentials.access_key_id, + secret_access_key: credentials.secret_access_key, + session_token: credentials.session_token + ) + end + + def provision_cluster + provider.api_client.create_stack( + stack_name: provider.cluster.name, + template_body: stack_template, + parameters: parameters, + capabilities: ["CAPABILITY_IAM"] + ) + end + + def parameters + [ + parameter('ClusterName', provider.cluster.name), + parameter('ClusterRole', provider.role_arn), + parameter('ClusterControlPlaneSecurityGroup', provider.security_group_id), + parameter('VpcId', provider.vpc_id), + parameter('Subnets', provider.subnet_ids.join(',')), + parameter('NodeAutoScalingGroupDesiredCapacity', provider.num_nodes.to_s), + parameter('NodeInstanceType', provider.instance_type), + parameter('KeyName', provider.key_name) + ] + end + + def parameter(key, value) + { parameter_key: key, parameter_value: value } + end + + def stack_template + File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) + end + end + end +end diff --git a/app/services/clusters/aws/proxy_service.rb b/app/services/clusters/aws/proxy_service.rb new file mode 100644 index 00000000000..df8fc480005 --- /dev/null +++ b/app/services/clusters/aws/proxy_service.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class ProxyService + DEFAULT_REGION = 'us-east-1' + + BadRequest = Class.new(StandardError) + Response = Struct.new(:status, :body) + + def initialize(role, params:) + @role = role + @params = params + end + + def execute + api_response = request_from_api! + + Response.new(:ok, api_response.to_hash) + rescue *service_errors + Response.new(:bad_request, {}) + end + + private + + attr_reader :role, :params + + def request_from_api! + case requested_resource + when 'key_pairs' + ec2_client.describe_key_pairs + + when 'instance_types' + instance_types + + when 'roles' + iam_client.list_roles + + when 'regions' + ec2_client.describe_regions + + when 'security_groups' + raise BadRequest unless vpc_id.present? + + ec2_client.describe_security_groups(vpc_filter) + + when 'subnets' + raise BadRequest unless vpc_id.present? + + ec2_client.describe_subnets(vpc_filter) + + when 'vpcs' + ec2_client.describe_vpcs + + else + raise BadRequest + end + end + + def requested_resource + params[:resource] + end + + def vpc_id + params[:vpc_id] + end + + def region + params[:region] || DEFAULT_REGION + end + + def vpc_filter + { + filters: [{ + name: "vpc-id", + values: [vpc_id] + }] + } + end + + ## + # Unfortunately the EC2 API doesn't provide a list of + # possible instance types. There is a workaround, using + # the Pricing API, but instead of requiring the + # user to grant extra permissions for this we use the + # values that validate the CloudFormation template. + def instance_types + { + instance_types: cluster_stack_instance_types.map { |type| Hash(instance_type_name: type) } + } + end + + def cluster_stack_instance_types + YAML.safe_load(stack_template).dig('Parameters', 'NodeInstanceType', 'AllowedValues') + end + + def stack_template + File.read(Rails.root.join('vendor', 'aws', 'cloudformation', 'eks_cluster.yaml')) + end + + def ec2_client + ::Aws::EC2::Client.new(client_options) + end + + def iam_client + ::Aws::IAM::Client.new(client_options) + end + + def credentials + Clusters::Aws::FetchCredentialsService.new(role, region: region).execute + end + + def client_options + { + credentials: credentials, + region: region, + http_open_timeout: 5, + http_read_timeout: 10 + } + end + + def service_errors + [ + BadRequest, + Clusters::Aws::FetchCredentialsService::MissingRoleError, + ::Aws::Errors::MissingCredentialsError, + ::Aws::EC2::Errors::ServiceError, + ::Aws::IAM::Errors::ServiceError, + ::Aws::STS::Errors::ServiceError + ] + end + end + end +end diff --git a/app/services/clusters/aws/verify_provision_status_service.rb b/app/services/clusters/aws/verify_provision_status_service.rb new file mode 100644 index 00000000000..99532662bc4 --- /dev/null +++ b/app/services/clusters/aws/verify_provision_status_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Clusters + module Aws + class VerifyProvisionStatusService + attr_reader :provider + + INITIAL_INTERVAL = 5.minutes + POLL_INTERVAL = 1.minute + TIMEOUT = 30.minutes + + def execute(provider) + @provider = provider + + case cluster_stack.stack_status + when 'CREATE_IN_PROGRESS' + continue_creation + when 'CREATE_COMPLETE' + finalize_creation + else + provider.make_errored!("Unexpected status; #{cluster_stack.stack_status}") + end + rescue ::Aws::CloudFormation::Errors::ServiceError => e + provider.make_errored!("Amazon CloudFormation request failed; #{e.message}") + end + + private + + def cluster_stack + @cluster_stack ||= provider.api_client.describe_stacks(stack_name: provider.cluster.name).stacks.first + end + + def continue_creation + if timeout_threshold.future? + WaitForClusterCreationWorker.perform_in(POLL_INTERVAL, provider.cluster_id) + else + provider.make_errored!(_('Kubernetes cluster creation time exceeds timeout; %{timeout}') % { timeout: TIMEOUT }) + end + end + + def timeout_threshold + cluster_stack.creation_time + TIMEOUT + end + + def finalize_creation + Clusters::Aws::FinalizeCreationService.new.execute(provider) + end + end + end +end diff --git a/app/services/clusters/destroy_service.rb b/app/services/clusters/destroy_service.rb new file mode 100644 index 00000000000..a8de04683fa --- /dev/null +++ b/app/services/clusters/destroy_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Clusters + class DestroyService + attr_reader :current_user, :params + + def initialize(user = nil, params = {}) + @current_user, @params = user, params.dup + @response = {} + end + + def execute(cluster) + cleanup? ? start_cleanup!(cluster) : destroy_cluster!(cluster) + + @response + end + + private + + def cleanup? + Gitlab::Utils.to_boolean(params[:cleanup]) + end + + def start_cleanup!(cluster) + cluster.start_cleanup! + @response[:message] = _('Kubernetes cluster integration and resources are being removed.') + end + + def destroy_cluster!(cluster) + cluster.destroy! + @response[:message] = _('Kubernetes cluster integration was successfully removed.') + end + end +end diff --git a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb index 8b8ad924b64..d798dcdcfd3 100644 --- a/app/services/clusters/kubernetes/create_or_update_service_account_service.rb +++ b/app/services/clusters/kubernetes/create_or_update_service_account_service.rb @@ -49,6 +49,8 @@ module Clusters create_or_update_knative_serving_role create_or_update_knative_serving_role_binding + create_or_update_crossplane_database_role + create_or_update_crossplane_database_role_binding end private @@ -78,6 +80,14 @@ module Clusters kubeclient.update_role_binding(knative_serving_role_binding_resource) end + def create_or_update_crossplane_database_role + kubeclient.update_role(crossplane_database_role_resource) + end + + def create_or_update_crossplane_database_role_binding + kubeclient.update_role_binding(crossplane_database_role_binding_resource) + end + def service_account_resource Gitlab::Kubernetes::ServiceAccount.new( service_account_name, @@ -134,6 +144,28 @@ module Clusters service_account_name: service_account_name ).generate end + + def crossplane_database_role_resource + Gitlab::Kubernetes::Role.new( + name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, + namespace: service_account_namespace, + rules: [{ + apiGroups: %w(database.crossplane.io), + resources: %w(postgresqlinstances), + verbs: %w(get list create watch) + }] + ).generate + end + + def crossplane_database_role_binding_resource + Gitlab::Kubernetes::RoleBinding.new( + name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME, + role_name: Clusters::Kubernetes::GITLAB_CROSSPLANE_DATABASE_ROLE_NAME, + role_kind: :Role, + namespace: service_account_namespace, + service_account_name: service_account_name + ).generate + end end end end diff --git a/app/services/clusters/kubernetes/kubernetes.rb b/app/services/clusters/kubernetes/kubernetes.rb index 7d5d0c2c1d6..d29519999b2 100644 --- a/app/services/clusters/kubernetes/kubernetes.rb +++ b/app/services/clusters/kubernetes/kubernetes.rb @@ -10,5 +10,7 @@ module Clusters PROJECT_CLUSTER_ROLE_NAME = 'edit' GITLAB_KNATIVE_SERVING_ROLE_NAME = 'gitlab-knative-serving-role' GITLAB_KNATIVE_SERVING_ROLE_BINDING_NAME = 'gitlab-knative-serving-rolebinding' + GITLAB_CROSSPLANE_DATABASE_ROLE_NAME = 'gitlab-crossplane-database-role' + GITLAB_CROSSPLANE_DATABASE_ROLE_BINDING_NAME = 'gitlab-crossplane-database-rolebinding' end end diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb index 25d26e761b1..8cb77040b14 100644 --- a/app/services/clusters/update_service.rb +++ b/app/services/clusters/update_service.rb @@ -9,7 +9,55 @@ module Clusters end def execute(cluster) - cluster.update(params) + if validate_params(cluster) + cluster.update(params) + else + false + end + end + + private + + def can_admin_pipeline_for_project?(project) + Ability.allowed?(current_user, :admin_pipeline, project) + end + + def validate_params(cluster) + if params[:management_project_id].present? + management_project = management_project_scope(cluster).find_by_id(params[:management_project_id]) + + unless management_project + cluster.errors.add(:management_project_id, _('Project does not exist or you don\'t have permission to perform this action')) + + return false + end + + unless can_admin_pipeline_for_project?(management_project) + # Use same message as not found to prevent enumeration + cluster.errors.add(:management_project_id, _('Project does not exist or you don\'t have permission to perform this action')) + + return false + end + end + + true + end + + def management_project_scope(cluster) + return ::Project.all if cluster.instance_type? + + group = + if cluster.group_type? + cluster.first_group + elsif cluster.project_type? + cluster.first_project&.namespace + end + + # Prevent users from selecting nested projects until + # https://gitlab.com/gitlab-org/gitlab/issues/34650 is resolved + include_subgroups = cluster.group_type? + + ::GroupProjectsFinder.new(group: group, current_user: current_user, options: { only_owned: true, include_subgroups: include_subgroups }).execute end end end diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb index 97fbb70f350..dbbe89ef260 100644 --- a/app/services/cohorts_service.rb +++ b/app/services/cohorts_service.rb @@ -88,7 +88,7 @@ class CohortsService User .where('created_at > ?', MONTHS_INCLUDED.months.ago.end_of_month) .group(created_at_month, last_activity_on_month) - .reorder("#{created_at_month} ASC", "#{last_activity_on_month} ASC") + .reorder(Arel.sql("#{created_at_month} ASC, #{last_activity_on_month} ASC")) .count end end diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index fbf71f02837..661e654406e 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -23,14 +23,15 @@ module Commits message, start_project: @start_project, start_branch_name: @start_branch) - rescue Gitlab::Git::Repository::CreateTreeError + rescue Gitlab::Git::Repository::CreateTreeError => ex act = action.to_s.dasherize type = @commit.change_type_title(current_user) error_msg = "Sorry, we cannot #{act} this #{type} automatically. " \ "This #{type} may already have been #{act}ed, or a more recent " \ "commit may have updated some of its content." - raise ChangeError, error_msg + + raise ChangeError.new(error_msg, ex.error_code) end end end diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index b5401a8ea37..b42494563b2 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -3,7 +3,15 @@ module Commits class CreateService < ::BaseService ValidationError = Class.new(StandardError) - ChangeError = Class.new(StandardError) + class ChangeError < StandardError + attr_reader :error_code + + def initialize(message, error_code = nil) + super(message) + + @error_code = error_code + end + end def initialize(*args) super @@ -21,8 +29,9 @@ module Commits new_commit = create_commit! success(result: new_commit) + rescue ChangeError => ex + error(ex.message, pass_back: { error_code: ex.error_code }) rescue ValidationError, - ChangeError, Gitlab::Git::Index::IndexError, Gitlab::Git::CommitError, Gitlab::Git::PreReceiveError, diff --git a/app/services/concerns/git/logger.rb b/app/services/concerns/git/logger.rb new file mode 100644 index 00000000000..7c036212e66 --- /dev/null +++ b/app/services/concerns/git/logger.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Git + module Logger + def log_error(message, save_message_on_model: false) + Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}") + merge_request.update(merge_error: message) if save_message_on_model + end + end +end diff --git a/app/services/create_branch_service.rb b/app/services/create_branch_service.rb index 110e589e30d..d58cb0f9e2b 100644 --- a/app/services/create_branch_service.rb +++ b/app/services/create_branch_service.rb @@ -14,7 +14,7 @@ class CreateBranchService < BaseService if new_branch success(new_branch) else - error('Invalid reference name') + error("Invalid reference name: #{branch_name}") end rescue Gitlab::Git::PreReceiveError => ex error(ex.message) diff --git a/app/services/deployments/after_create_service.rb b/app/services/deployments/after_create_service.rb index 2572802e6a1..e0a4e5419cc 100644 --- a/app/services/deployments/after_create_service.rb +++ b/app/services/deployments/after_create_service.rb @@ -33,12 +33,21 @@ module Deployments if environment.save && !environment.stopped? deployment.update_merge_request_metrics! + link_merge_requests(deployment) end end end private + def link_merge_requests(deployment) + unless Feature.enabled?(:deployment_merge_requests, deployment.project) + return + end + + LinkMergeRequestsService.new(deployment).execute + end + def environment_options options&.dig(:environment) || {} end diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb new file mode 100644 index 00000000000..71186659290 --- /dev/null +++ b/app/services/deployments/link_merge_requests_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Deployments + # Service class for linking merge requests to deployments. + class LinkMergeRequestsService + attr_reader :deployment + + # The number of commits per query for which to find merge requests. + COMMITS_PER_QUERY = 5_000 + + def initialize(deployment) + @deployment = deployment + end + + def execute + return unless deployment.success? + + if (prev = deployment.previous_environment_deployment) + link_merge_requests_for_range(prev.sha, deployment.sha) + else + # When no previous deployment is found we fall back to linking all merge + # requests merged into the deployed branch. This will not always be + # accurate, but it's better than having no data. + # + # We can't use the first commit in the repository as a base to compare + # to, as this will not scale to large repositories. For example, GitLab + # itself has over 150 000 commits. + link_all_merged_merge_requests + end + end + + def link_merge_requests_for_range(from, to) + commits = project + .repository + .commits_between(from, to) + .map(&:id) + + # For some projects the list of commits to deploy may be very large. To + # ensure we do not end up running SQL queries with thousands of WHERE IN + # values, we run one query per a certain number of commits. + # + # In most cases this translates to only a single query. For very large + # deployment we may end up running a handful of queries to get and insert + # the data. + commits.each_slice(COMMITS_PER_QUERY) do |slice| + merge_requests = + project.merge_requests.merged.by_merge_commit_sha(slice) + + deployment.link_merge_requests(merge_requests) + end + end + + def link_all_merged_merge_requests + merge_requests = + project.merge_requests.merged.by_target_branch(deployment.ref) + + deployment.link_merge_requests(merge_requests) + end + + private + + def project + deployment.project + end + end +end diff --git a/app/services/deployments/update_service.rb b/app/services/deployments/update_service.rb index 7c8215d28f2..97b233f16a7 100644 --- a/app/services/deployments/update_service.rb +++ b/app/services/deployments/update_service.rb @@ -10,7 +10,22 @@ module Deployments end def execute - deployment.update(status: params[:status]) + # A regular update() does not trigger the state machine transitions, which + # we need to ensure merge requests are linked when changing the status to + # success. To work around this we use this case statment, using the right + # event methods to trigger the transition hooks. + case params[:status] + when 'running' + deployment.run + when 'success' + deployment.succeed + when 'failed' + deployment.drop + when 'canceled' + deployment.cancel + else + false + end end end end diff --git a/app/services/error_tracking/base_service.rb b/app/services/error_tracking/base_service.rb new file mode 100644 index 00000000000..430d9952332 --- /dev/null +++ b/app/services/error_tracking/base_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ErrorTracking + class BaseService < ::BaseService + def execute + unauthorized = check_permissions + return unauthorized if unauthorized + + begin + response = fetch + rescue Sentry::Client::Error => e + return error(e.message, :bad_request) + rescue Sentry::Client::MissingKeysError => e + return error(e.message, :internal_server_error) + end + + errors = parse_errors(response) + return errors if errors + + success(parse_response(response)) + end + + private + + def fetch + raise NotImplementedError, + "#{self.class} does not implement #{__method__}" + end + + def parse_response(response) + raise NotImplementedError, + "#{self.class} does not implement #{__method__}" + end + + def check_permissions + return error('Error Tracking is not enabled') unless enabled? + return error('Access denied', :unauthorized) unless can_read? + end + + def parse_errors(response) + return error('Not ready. Try again later', :no_content) unless response + return error(response[:error], http_status_for(response[:error_type])) if response[:error].present? + end + + def http_status_for(error_type) + case error_type + when ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS + :internal_server_error + else + :bad_request + end + end + + def project_error_tracking_setting + project.error_tracking_setting + end + + def enabled? + project_error_tracking_setting&.enabled? + end + + def can_read? + can?(current_user, :read_sentry_issue, project) + end + end +end diff --git a/app/services/error_tracking/issue_details_service.rb b/app/services/error_tracking/issue_details_service.rb new file mode 100644 index 00000000000..368cd4517fc --- /dev/null +++ b/app/services/error_tracking/issue_details_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ErrorTracking + class IssueDetailsService < ErrorTracking::BaseService + private + + def fetch + project_error_tracking_setting.issue_details(issue_id: params[:issue_id]) + end + + def parse_response(response) + { issue: response[:issue] } + end + end +end diff --git a/app/services/error_tracking/issue_latest_event_service.rb b/app/services/error_tracking/issue_latest_event_service.rb new file mode 100644 index 00000000000..b6ad8f8028b --- /dev/null +++ b/app/services/error_tracking/issue_latest_event_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ErrorTracking + class IssueLatestEventService < ErrorTracking::BaseService + private + + def fetch + project_error_tracking_setting.issue_latest_event(issue_id: params[:issue_id]) + end + + def parse_response(response) + { latest_event: response[:latest_event] } + end + end +end diff --git a/app/services/error_tracking/list_issues_service.rb b/app/services/error_tracking/list_issues_service.rb index 86ab21fa865..2e8c401b8ef 100644 --- a/app/services/error_tracking/list_issues_service.rb +++ b/app/services/error_tracking/list_issues_service.rb @@ -1,46 +1,22 @@ # frozen_string_literal: true module ErrorTracking - class ListIssuesService < ::BaseService + class ListIssuesService < ErrorTracking::BaseService DEFAULT_ISSUE_STATUS = 'unresolved' DEFAULT_LIMIT = 20 - def execute - return error('Error Tracking is not enabled') unless enabled? - return error('Access denied', :unauthorized) unless can_read? - - result = project_error_tracking_setting - .list_sentry_issues(issue_status: issue_status, limit: limit) - - # our results are not yet ready - unless result - return error('Not ready. Try again later', :no_content) - end - - if result[:error].present? - return error(result[:error], http_status_from_error_type(result[:error_type])) - end - - success(issues: result[:issues]) - end - def external_url project_error_tracking_setting&.sentry_external_url end private - def http_status_from_error_type(error_type) - case error_type - when ErrorTracking::ProjectErrorTrackingSetting::SENTRY_API_ERROR_TYPE_MISSING_KEYS - :internal_server_error - else - :bad_request - end + def fetch + project_error_tracking_setting.list_sentry_issues(issue_status: issue_status, limit: limit) end - def project_error_tracking_setting - project.error_tracking_setting + def parse_response(response) + { issues: response[:issues] } end def issue_status @@ -50,13 +26,5 @@ module ErrorTracking def limit params[:limit] || DEFAULT_LIMIT end - - def enabled? - project_error_tracking_setting&.enabled? - end - - def can_read? - can?(current_user, :read_sentry_issue, project) - end end end diff --git a/app/services/error_tracking/list_projects_service.rb b/app/services/error_tracking/list_projects_service.rb index 92d4ef85ecf..09a0b952e84 100644 --- a/app/services/error_tracking/list_projects_service.rb +++ b/app/services/error_tracking/list_projects_service.rb @@ -1,44 +1,38 @@ # frozen_string_literal: true module ErrorTracking - class ListProjectsService < ::BaseService + class ListProjectsService < ErrorTracking::BaseService def execute - return error('access denied') unless can_read? - - setting = project_error_tracking_setting - - unless setting.valid? - return error(setting.errors.full_messages.join(', '), :bad_request) + unless project_error_tracking_setting.valid? + return error(project_error_tracking_setting.errors.full_messages.join(', '), :bad_request) end - begin - result = setting.list_sentry_projects - rescue Sentry::Client::Error => e - return error(e.message, :bad_request) - rescue Sentry::Client::MissingKeysError => e - return error(e.message, :internal_server_error) - end - - success(projects: result[:projects]) + super end private - def project_error_tracking_setting - (project.error_tracking_setting || project.build_error_tracking_setting).tap do |setting| - setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( - api_host: params[:api_host], - organization_slug: 'org', - project_slug: 'proj' - ) - - setting.token = token(setting) - setting.enabled = true - end + def fetch + project_error_tracking_setting.list_sentry_projects + end + + def parse_response(response) + { projects: response[:projects] } end - def can_read? - can?(current_user, :read_sentry_issue, project) + def project_error_tracking_setting + @project_error_tracking_setting ||= begin + (super || project.build_error_tracking_setting).tap do |setting| + setting.api_url = ErrorTracking::ProjectErrorTrackingSetting.build_api_url_from( + api_host: params[:api_host], + organization_slug: 'org', + project_slug: 'proj' + ) + + setting.token = token(setting) + setting.enabled = true + end + end end def token(setting) diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb new file mode 100644 index 00000000000..2ce53fcfe4a --- /dev/null +++ b/app/services/groups/group_links/create_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Groups + module GroupLinks + class CreateService < BaseService + def execute(shared_group) + unless group && shared_group && + can?(current_user, :admin_group, shared_group) && + can?(current_user, :read_group, group) + return error('Not Found', 404) + end + + link = GroupGroupLink.new( + shared_group: shared_group, + shared_with_group: group, + group_access: params[:shared_group_access], + expires_at: params[:expires_at] + ) + + if link.save + group.refresh_members_authorized_projects + success(link: link) + else + error(link.errors.full_messages.to_sentence, 409) + end + end + end + end +end diff --git a/app/services/groups/group_links/destroy_service.rb b/app/services/groups/group_links/destroy_service.rb new file mode 100644 index 00000000000..29aa8de4e68 --- /dev/null +++ b/app/services/groups/group_links/destroy_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Groups + module GroupLinks + class DestroyService < BaseService + def execute(one_or_more_links) + links = Array(one_or_more_links) + + GroupGroupLink.transaction do + GroupGroupLink.delete(links) + + groups_to_refresh = links.map(&:shared_with_group) + groups_to_refresh.uniq.each do |group| + group.refresh_members_authorized_projects + end + + Gitlab::AppLogger.info("GroupGroupLinks with ids: #{links.map(&:id)} have been deleted.") + rescue => ex + Gitlab::AppLogger.error(ex) + + raise + end + end + end + end +end diff --git a/app/services/groups/import_export/export_service.rb b/app/services/groups/import_export/export_service.rb new file mode 100644 index 00000000000..26886fc67dc --- /dev/null +++ b/app/services/groups/import_export/export_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Groups + module ImportExport + class ExportService + def initialize(group:, user:, params: {}) + @group = group + @current_user = user + @params = params + @shared = @params[:shared] || Gitlab::ImportExport::Shared.new(@group) + end + + def execute + save! + end + + private + + attr_accessor :shared + + def save! + if savers.all?(&:save) + notify_success + else + cleanup_and_notify_error! + end + end + + def savers + [tree_exporter, file_saver] + end + + def tree_exporter + Gitlab::ImportExport::GroupTreeSaver.new(group: @group, current_user: @current_user, shared: @shared, params: @params) + end + + def file_saver + Gitlab::ImportExport::Saver.new(exportable: @group, shared: @shared) + end + + def cleanup_and_notify_error + FileUtils.rm_rf(shared.export_path) + + notify_error + end + + def cleanup_and_notify_error! + cleanup_and_notify_error + + raise Gitlab::ImportExport::Error.new(shared.errors.to_sentence) + end + + def notify_success + @shared.logger.info( + group_id: @group.id, + group_name: @group.name, + message: 'Group Import/Export: Export succeeded' + ) + end + + def notify_error + @shared.logger.error( + group_id: @group.id, + group_name: @group.name, + error: @shared.errors.join(', '), + message: 'Group Import/Export: Export failed' + ) + end + end + end +end diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index 6902b7bd529..24813f6ddf9 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -13,7 +13,7 @@ module Groups TransferError = Class.new(StandardError) - attr_reader :error + attr_reader :error, :new_parent_group def initialize(group, user, params = {}) super @@ -75,7 +75,7 @@ module Groups # rubocop: enable CodeReuse/ActiveRecord def group_projects_contain_registry_images? - @group.has_container_repositories? + @group.has_container_repository_including_subgroups? end def update_group_attributes @@ -115,3 +115,5 @@ module Groups end end end + +Groups::TransferService.prepend_if_ee('EE::Groups::TransferService') diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index be7502a193e..8635b82461b 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -43,8 +43,9 @@ module Groups def renaming_group_with_container_registry_images? new_path = params[:path] - new_path && new_path != group.path && - group.has_container_repositories? + new_path && + new_path != group.path && + group.has_container_repository_including_subgroups? end def container_images_error diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 3e17d75c02c..8a79c5f889d 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -355,7 +355,7 @@ class IssuableBaseService < BaseService associations = { labels: issuable.labels.to_a, - mentioned_users: issuable.mentioned_users.to_a, + mentioned_users: issuable.mentioned_users(current_user).to_a, assignees: issuable.assignees.to_a } associations[:total_time_spent] = issuable.total_time_spent if issuable.respond_to?(:total_time_spent) diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 528b1ea61b3..b98a4d2567f 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -61,8 +61,6 @@ module Issues if added_mentions.present? notification_service.async.new_mentions_in_issue(issue, added_mentions, current_user) end - - ZoomNotesService.new(issue, project, current_user, old_description: old_associations[:description]).execute end def handle_task_changes(issuable) diff --git a/app/services/issues/zoom_link_service.rb b/app/services/issues/zoom_link_service.rb index 561c86475e5..023d7080e88 100644 --- a/app/services/issues/zoom_link_service.rb +++ b/app/services/issues/zoom_link_service.rb @@ -6,32 +6,37 @@ module Issues super(issue.project, user) @issue = issue + @added_meeting = ZoomMeeting.canonical_meeting(@issue) end def add_link(link) if can_add_link? && (link = parse_link(link)) - track_meeting_added_event - success(_('Zoom meeting added'), append_to_description(link)) + begin + add_zoom_meeting(link) + success(_('Zoom meeting added')) + rescue ActiveRecord::RecordNotUnique + error(_('Failed to add a Zoom meeting')) + end else error(_('Failed to add a Zoom meeting')) end end - def can_add_link? - can? && !link_in_issue_description? - end - def remove_link if can_remove_link? - track_meeting_removed_event - success(_('Zoom meeting removed'), remove_from_description) + remove_zoom_meeting + success(_('Zoom meeting removed')) else error(_('Failed to remove a Zoom meeting')) end end + def can_add_link? + can_update_issue? && !@added_meeting + end + def can_remove_link? - can? && link_in_issue_description? + can_update_issue? && !!@added_meeting end def parse_link(link) @@ -42,10 +47,6 @@ module Issues attr_reader :issue - def issue_description - issue.description || '' - end - def track_meeting_added_event ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'add_zoom_meeting', label: 'Issue ID', value: issue.id) end @@ -54,39 +55,33 @@ module Issues ::Gitlab::Tracking.event('IncidentManagement::ZoomIntegration', 'remove_zoom_meeting', label: 'Issue ID', value: issue.id) end - def success(message, description) - ServiceResponse - .success(message: message, payload: { description: description }) - end - - def error(message) - ServiceResponse.error(message: message) + def add_zoom_meeting(link) + ZoomMeeting.create( + issue: @issue, + project: @issue.project, + issue_status: :added, + url: link + ) + track_meeting_added_event + SystemNoteService.zoom_link_added(@issue, @project, current_user) end - def append_to_description(link) - "#{issue_description}\n\n#{link}" + def remove_zoom_meeting + @added_meeting.update(issue_status: :removed) + track_meeting_removed_event + SystemNoteService.zoom_link_removed(@issue, @project, current_user) end - def remove_from_description - link = parse_link(issue_description) - return issue_description unless link - - issue_description.delete_suffix(link).rstrip + def success(message) + ServiceResponse.success(message: message) end - def link_in_issue_description? - link = extract_link_from_issue_description - return unless link - - Gitlab::ZoomLinkExtractor.new(link).match? - end - - def extract_link_from_issue_description - issue_description[/(\S+)\z/, 1] + def error(message) + ServiceResponse.error(message: message) end - def can? - current_user.can?(:update_issue, project) + def can_update_issue? + can?(current_user, :update_issue, project) end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index aacc3d6831e..00bf69739ad 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -29,6 +29,19 @@ module MergeRequests .execute_for_merge_request(merge_request) end + def source_project + @source_project ||= merge_request.source_project + end + + def target_project + @target_project ||= merge_request.target_project + end + + # Don't try to print expensive instance variables. + def inspect + "#<#{self.class} #{merge_request.to_reference(full: true)}>" + end + private def create(merge_request) diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index bf4da01723b..456cc589477 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -16,6 +16,14 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project + # Source project sets the default source branch removal setting + merge_request.merge_params['force_remove_source_branch'] = + if params.key?(:force_remove_source_branch) + params.delete(:force_remove_source_branch) + else + merge_request.source_project.remove_source_branch_after_merge? + end + self.params = assign_allowed_merge_params(merge_request, params) filter_params(merge_request) diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index 479e0fe6699..6f1fa607ef9 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -11,10 +11,16 @@ module MergeRequests private def commit - repository.ff_merge(current_user, - source, - merge_request.target_branch, - merge_request: merge_request) + ff_merge = repository.ff_merge(current_user, + source, + merge_request.target_branch, + merge_request: merge_request) + + if merge_request.squash + merge_request.update_column(:squash_commit_sha, merge_request.in_progress_merge_commit_sha) + end + + ff_merge rescue Gitlab::Git::PreReceiveError => e raise MergeError, e.message rescue StandardError => e diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb index 3f7f8bcdcbf..27b5e31faab 100644 --- a/app/services/merge_requests/merge_base_service.rb +++ b/app/services/merge_requests/merge_base_service.rb @@ -19,10 +19,12 @@ module MergeRequests end def source - if merge_request.squash - squash_sha! - else - merge_request.diff_head_sha + strong_memoize(:source) do + if merge_request.squash + squash_sha! + else + merge_request.diff_head_sha + end end end @@ -58,16 +60,14 @@ module MergeRequests end def squash_sha! - strong_memoize(:squash_sha) do - params[:merge_request] = merge_request - squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute - - case squash_result[:status] - when :success - squash_result[:squash_sha] - when :error - raise ::MergeRequests::MergeService::MergeError, squash_result[:message] - end + params[:merge_request] = merge_request + squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute + + case squash_result[:status] + when :success + squash_result[:squash_sha] + when :error + raise ::MergeRequests::MergeService::MergeError, squash_result[:message] end end end diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 6309052244d..a45b4f1142e 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -37,6 +37,7 @@ module MergeRequests def validate! authorization_check! error_check! + updated_check! end def authorization_check! @@ -60,6 +61,15 @@ module MergeRequests raise_error(error) if error end + def updated_check! + return unless Feature.enabled?(:validate_merge_sha, merge_request.target_project, default_enabled: false) + + unless source_matches? + raise_error('Branch has been updated since the merge was requested. '\ + 'Please review the changes.') + end + end + def commit log_info("Git merge started on JID #{merge_jid}") commit_id = try_merge @@ -125,5 +135,11 @@ module MergeRequests def merge_request_info merge_request.to_reference(full: true) end + + def source_matches? + # params-keys are symbols coming from the controller, but when they get + # loaded from the database they're strings + params.with_indifferent_access[:sha] == merge_request.diff_head_sha + end end end diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb index 4d36dd4feae..7e9442c0c7c 100644 --- a/app/services/merge_requests/rebase_service.rb +++ b/app/services/merge_requests/rebase_service.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true module MergeRequests - class RebaseService < MergeRequests::WorkingCopyBaseService + class RebaseService < MergeRequests::BaseService + include Git::Logger + REBASE_ERROR = 'Rebase failed. Please rebase locally' + attr_reader :merge_request + def execute(merge_request) @merge_request = merge_request diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index b32499629ff..bd3fcf85a62 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -152,7 +152,8 @@ module MergeRequests def abort_ff_merge_requests_with_when_pipeline_succeeds return unless @project.ff_merge_must_be_possible? - requests_with_auto_merge_enabled_to(@push.branch_name).each do |merge_request| + merge_requests_with_auto_merge_enabled_to(@push.branch_name).each do |merge_request| + next unless merge_request.auto_merge_strategy == AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS next unless merge_request.should_be_rebased? abort_auto_merge_with_todo(merge_request, 'target branch was updated') @@ -167,11 +168,11 @@ module MergeRequests todo_service.merge_request_became_unmergeable(merge_request) end - def requests_with_auto_merge_enabled_to(target_branch) + def merge_requests_with_auto_merge_enabled_to(target_branch) @project .merge_requests .by_target_branch(target_branch) - .with_open_merge_when_pipeline_succeeds + .with_auto_merge_enabled end def mark_pending_todos_done diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb index 88ca3b4f5a8..d25997c925e 100644 --- a/app/services/merge_requests/squash_service.rb +++ b/app/services/merge_requests/squash_service.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true module MergeRequests - class SquashService < MergeRequests::WorkingCopyBaseService + class SquashService < MergeRequests::BaseService + include Git::Logger + def execute # If performing a squash would result in no change, then # immediately return a success message without performing a squash diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 7c9abb12b6e..8a6a7119508 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -88,9 +88,9 @@ module MergeRequests merge_request.update(merge_error: nil) if merge_request.head_pipeline && merge_request.head_pipeline.active? - AutoMergeService.new(project, current_user).execute(merge_request, AutoMergeService::STRATEGY_MERGE_WHEN_PIPELINE_SUCCEEDS) + 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, {}) + merge_request.merge_async(current_user.id, { sha: last_diff_sha }) end end diff --git a/app/services/merge_requests/working_copy_base_service.rb b/app/services/merge_requests/working_copy_base_service.rb deleted file mode 100644 index 2d2be1f4c25..00000000000 --- a/app/services/merge_requests/working_copy_base_service.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class WorkingCopyBaseService < MergeRequests::BaseService - attr_reader :merge_request - - def source_project - @source_project ||= merge_request.source_project - end - - def target_project - @target_project ||= merge_request.target_project - end - - def log_error(message, save_message_on_model: false) - Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}") - - merge_request.update(merge_error: message) if save_message_on_model - end - - # Don't try to print expensive instance variables. - def inspect - "#<#{self.class} #{merge_request.to_reference(full: true)}>" - end - end -end diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb index 50f070989fc..79a556b1695 100644 --- a/app/services/metrics/dashboard/custom_metric_embed_service.rb +++ b/app/services/metrics/dashboard/custom_metric_embed_service.rb @@ -40,7 +40,7 @@ module Metrics # All custom metrics are displayed on the system dashboard. # Nil is acceptable as we'll default to the system dashboard. def valid_dashboard?(dashboard) - dashboard.nil? || SystemDashboardService.system_dashboard?(dashboard) + dashboard.nil? || ::Metrics::Dashboard::SystemDashboardService.system_dashboard?(dashboard) end end @@ -77,15 +77,14 @@ module Metrics # There may be multiple metrics, but they should be # displayed in a single panel/chart. # @return [ActiveRecord::AssociationRelation<PromtheusMetric>] - # rubocop: disable CodeReuse/ActiveRecord def metrics - project.prometheus_metrics.where( + PrometheusMetricsFinder.new( + project: project, group: group_key, title: title, y_label: y_label - ) + ).execute end - # rubocop: enable CodeReuse/ActiveRecord # Returns a symbol representing the group that # the dashboard's group title belongs to. diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb new file mode 100644 index 00000000000..60591e9a6f3 --- /dev/null +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +# Responsible for returning a gitlab-compatible dashboard +# containing info based on a grafana dashboard and datasource. +# +# Use Gitlab::Metrics::Dashboard::Finder to retrive dashboards. +module Metrics + module Dashboard + class GrafanaMetricEmbedService < ::Metrics::Dashboard::BaseService + include ReactiveCaching + + SEQUENCE = [ + ::Gitlab::Metrics::Dashboard::Stages::GrafanaFormatter + ].freeze + + self.reactive_cache_key = ->(service) { service.cache_key } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.minutes + self.reactive_cache_lifetime = 30.minutes + self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } + + class << self + # Determines whether the provided params are sufficient + # to uniquely identify a grafana dashboard. + def valid_params?(params) + [ + params[:embedded], + params[:grafana_url] + ].all? + end + + def from_cache(project_id, user_id, grafana_url) + project = Project.find(project_id) + user = User.find(user_id) + + new(project, user, grafana_url: grafana_url) + end + end + + def get_dashboard + with_reactive_cache(*cache_key) { |result| result } + end + + # Inherits the primary logic from the parent class and + # maintains the service's API while including ReactiveCache + def calculate_reactive_cache(*) + # This is called with explicit parentheses to prevent + # the params passed to #calculate_reactive_cache from + # being passed to #get_dashboard (which accepts none) + ::Metrics::Dashboard::BaseService + .instance_method(:get_dashboard) + .bind(self) + .call() # rubocop:disable Style/MethodCallWithoutArgsParentheses + end + + def cache_key(*args) + [project.id, current_user.id, grafana_url] + end + + # Required for ReactiveCaching; Usage overridden by + # self.reactive_cache_worker_finder + def id + nil + end + + private + + def get_raw_dashboard + raise MissingIntegrationError unless client + + grafana_dashboard = fetch_dashboard + datasource = fetch_datasource(grafana_dashboard) + + params.merge!(grafana_dashboard: grafana_dashboard, datasource: datasource) + + {} + end + + def fetch_dashboard + uid = GrafanaUidParser.new(grafana_url, project).parse + raise DashboardProcessingError.new('Dashboard uid not found') unless uid + + response = client.get_dashboard(uid: uid) + + parse_json(response.body) + end + + def fetch_datasource(dashboard) + name = DatasourceNameParser.new(grafana_url, dashboard).parse + raise DashboardProcessingError.new('Datasource name not found') unless name + + response = client.get_datasource(name: name) + + parse_json(response.body) + end + + def grafana_url + params[:grafana_url] + end + + def client + project.grafana_integration&.client + end + + def allowed? + Ability.allowed?(current_user, :read_project, project) + end + + def sequence + SEQUENCE + end + + def parse_json(json) + JSON.parse(json, symbolize_names: true) + rescue JSON::ParserError + raise DashboardProcessingError.new('Grafana response contains invalid json') + end + end + + # Identifies the uid of the dashboard based on url format + class GrafanaUidParser + def initialize(grafana_url, project) + @grafana_url, @project = grafana_url, project + end + + def parse + @grafana_url.match(uid_regex) { |m| m.named_captures['uid'] } + end + + private + + # URLs are expected to look like https://domain.com/d/:uid/other/stuff + def uid_regex + base_url = @project.grafana_integration.grafana_url.chomp('/') + + %r{(#{Regexp.escape(base_url)}\/d\/(?<uid>\w+)\/)}x + end + end + + # Identifies the name of the datasource for a dashboard + # based on the panelId query parameter found in the url + class DatasourceNameParser + def initialize(grafana_url, grafana_dashboard) + @grafana_url, @grafana_dashboard = grafana_url, grafana_dashboard + end + + def parse + @grafana_dashboard[:dashboard][:panels] + .find { |panel| panel[:id].to_s == query_params[:panelId] } + .try(:[], :datasource) + end + + private + + def query_params + Gitlab::Metrics::Dashboard::Url.parse_query(@grafana_url) + end + end + end +end diff --git a/app/services/metrics/dashboard/project_dashboard_service.rb b/app/services/metrics/dashboard/project_dashboard_service.rb index 756d387c0e6..b0d54ee9347 100644 --- a/app/services/metrics/dashboard/project_dashboard_service.rb +++ b/app/services/metrics/dashboard/project_dashboard_service.rb @@ -16,7 +16,8 @@ module Metrics { path: filepath, display_name: name_for_path(filepath), - default: false + default: false, + system_dashboard: false } end end diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb index ccfd9db8746..f8dbb8a705c 100644 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ b/app/services/metrics/dashboard/system_dashboard_service.rb @@ -20,7 +20,8 @@ module Metrics [{ path: SYSTEM_DASHBOARD_PATH, display_name: SYSTEM_DASHBOARD_NAME, - default: true + default: true, + system_dashboard: true }] end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index c136803ef3b..9e6cbfa06fe 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -42,6 +42,10 @@ module Notes clear_noteable_diffs_cache(note) Suggestions::CreateService.new(note).execute increment_usage_counter(note) + + if Feature.enabled?(:notes_create_service_tracking, project) + Gitlab::Tracking.event('Notes::CreateService', 'execute', tracking_data_for(note)) + end end if quick_actions_service.commands_executed_count.to_i > 0 @@ -59,5 +63,16 @@ module Notes note end + + private + + def tracking_data_for(note) + label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note' + + { + label: label, + value: note.id + } + end end end diff --git a/app/services/notes/post_process_service.rb b/app/services/notes/post_process_service.rb index 48722cc2a79..53b3b57f4af 100644 --- a/app/services/notes/post_process_service.rb +++ b/app/services/notes/post_process_service.rb @@ -35,3 +35,5 @@ module Notes end end end + +Notes::PostProcessService.prepend_if_ee('EE::Notes::PostProcessService') diff --git a/app/services/notification_recipient_service.rb b/app/services/notification_recipient_service.rb index 9afbb678f5d..0bdf6a0e6bc 100644 --- a/app/services/notification_recipient_service.rb +++ b/app/services/notification_recipient_service.rb @@ -181,7 +181,7 @@ module NotificationRecipientService def add_subscribed_users return unless target.respond_to? :subscribers - add_recipients(target.subscribers(project), :subscription, nil) + add_recipients(target.subscribers(project), :subscription, NotificationReason::SUBSCRIBED) end # rubocop: disable CodeReuse/ActiveRecord @@ -240,7 +240,7 @@ module NotificationRecipientService return unless target.respond_to? :labels (labels || target.labels).each do |label| - add_recipients(label.subscribers(project), :subscription, nil) + add_recipients(label.subscribers(project), :subscription, NotificationReason::SUBSCRIBED) end end end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index 2b4c4ae68e2..afe2651b11a 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -16,8 +16,12 @@ class PreviewMarkdownService < BaseService private + def quick_action_types + %w(Issue MergeRequest Commit) + end + def explain_quick_actions(text) - return text, [] unless %w(Issue MergeRequest Commit).include?(target_type) + return text, [] unless quick_action_types.include?(target_type) quick_actions_service = QuickActions::InterpretService.new(project, current_user) quick_actions_service.explain(text, find_commands_target) @@ -51,7 +55,7 @@ class PreviewMarkdownService < BaseService def find_commands_target QuickActions::TargetService - .new(project, current_user) + .new(project, current_user, group: params[:group]) .execute(target_type, target_id) end @@ -63,3 +67,5 @@ class PreviewMarkdownService < BaseService params[:target_id] end end + +PreviewMarkdownService.prepend_if_ee('EE::PreviewMarkdownService') diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index 5129e2269a8..48bd9394dc5 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -9,25 +9,11 @@ module Projects tag_names = params[:tags] return error('not tags specified') if tag_names.blank? - if can_use? - smart_delete(container_repository, tag_names) - else - unsafe_delete(container_repository, tag_names) - end + smart_delete(container_repository, tag_names) end private - def unsafe_delete(container_repository, tag_names) - deleted_tags = tag_names.select do |tag_name| - container_repository.tag(tag_name).unsafe_delete - end - - return error('could not delete tags') if deleted_tags.empty? - - success(deleted: deleted_tags) - end - # Replace a tag on the registry with a dummy tag. # This is a hack as the registry doesn't support deleting individual # tags. This code effectively pushes a dummy image and assigns the tag to it. @@ -36,10 +22,18 @@ module Projects def smart_delete(container_repository, tag_names) # generates the blobs for the dummy image dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path) + return error('could not generate manifest') if dummy_manifest.nil? # update the manifests of the tags with the new dummy image - tag_digests = tag_names.map do |name| - container_repository.client.put_tag(container_repository.path, name, dummy_manifest) + deleted_tags = [] + tag_digests = [] + + tag_names.each do |name| + digest = container_repository.client.put_tag(container_repository.path, name, dummy_manifest) + next unless digest + + deleted_tags << name + tag_digests << digest end # make sure the digests are the same (it should always be) @@ -51,16 +45,12 @@ module Projects # Deletes the dummy image # All created tag digests are the same since they all have the same dummy image. # a single delete is sufficient to remove all tags with it - if container_repository.delete_tag_by_digest(tag_digests.first) - success(deleted: tag_names) + if tag_digests.any? && container_repository.delete_tag_by_digest(tag_digests.first) + success(deleted: deleted_tags) else error('could not delete tags') end end - - def can_use? - Feature.enabled?(:container_registry_smart_delete, project, default_enabled: true) - end end end end diff --git a/app/services/projects/hashed_storage/base_attachment_service.rb b/app/services/projects/hashed_storage/base_attachment_service.rb index 828ab616bab..f8852c206e3 100644 --- a/app/services/projects/hashed_storage/base_attachment_service.rb +++ b/app/services/projects/hashed_storage/base_attachment_service.rb @@ -16,6 +16,12 @@ module Projects # Returns the logger currently in use attr_reader :logger + def initialize(project:, old_disk_path:, logger: nil) + @project = project + @old_disk_path = old_disk_path + @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger + end + # Return whether this operation was skipped or not # # @return [Boolean] true if skipped of false otherwise @@ -23,6 +29,14 @@ module Projects @skipped end + # Check if target path has discardable content + # + # @param [String] new_path + # @return [Boolean] whether we can discard the target path or not + def target_path_discardable?(new_path) + false + end + protected def move_folder!(old_path, new_path) @@ -34,8 +48,13 @@ module Projects end if File.exist?(new_path) - logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") - raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists" + if target_path_discardable?(new_path) + discard_path!(new_path) + else + logger.error("Cannot move attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})") + + raise AttachmentCannotMoveError, "Target path '#{new_path}' already exists" + end end # Create base path folder on the new storage layout @@ -46,6 +65,16 @@ module Projects true end + + # Rename a path adding a suffix in order to prevent data-loss. + # + # @param [String] new_path + def discard_path!(new_path) + discarded_path = "#{new_path}-#{Time.now.utc.to_i}" + + logger.info("Moving existing empty attachments folder from '#{new_path}' to '#{discarded_path}', (PROJECT_ID=#{project.id})") + FileUtils.mv(new_path, discarded_path) + end end end end diff --git a/app/services/projects/hashed_storage/base_repository_service.rb b/app/services/projects/hashed_storage/base_repository_service.rb index b7e9d3e8791..8b1bcaf17b7 100644 --- a/app/services/projects/hashed_storage/base_repository_service.rb +++ b/app/services/projects/hashed_storage/base_repository_service.rb @@ -10,7 +10,7 @@ module Projects attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger, :move_wiki - def initialize(project, old_disk_path, logger: nil) + def initialize(project:, old_disk_path:, logger: nil) @project = project @logger = logger || Gitlab::AppLogger @old_disk_path = old_disk_path diff --git a/app/services/projects/hashed_storage/migrate_attachments_service.rb b/app/services/projects/hashed_storage/migrate_attachments_service.rb index 0cbff283102..3d9d03c4a95 100644 --- a/app/services/projects/hashed_storage/migrate_attachments_service.rb +++ b/app/services/projects/hashed_storage/migrate_attachments_service.rb @@ -3,18 +3,19 @@ module Projects module HashedStorage class MigrateAttachmentsService < BaseAttachmentService - def initialize(project, old_disk_path, logger: nil) - @project = project - @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger - @old_disk_path = old_disk_path + extend ::Gitlab::Utils::Override + + # List of paths that can be excluded while evaluation if a target can be discarded + DISCARDABLE_PATHS = %w(tmp tmp/cache tmp/work).freeze + + def initialize(project:, old_disk_path:, logger: nil) + super + @skipped = false end def execute - origin = FileUploader.absolute_base_dir(project) - # It's possible that old_disk_path does not match project.disk_path. - # For example, that happens when we rename a project - origin.sub!(/#{Regexp.escape(project.full_path)}\z/, old_disk_path) + origin = find_old_attachments_path(project) project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments] target = FileUploader.absolute_base_dir(project) @@ -27,13 +28,38 @@ module Projects project.save!(validate: false) yield if block_given? - else - # Rollback changes - project.rollback! end result end + + override :target_path_discardable? + # Check if target path has discardable content + # + # @param [String] new_path + # @return [Boolean] whether we can discard the target path or not + def target_path_discardable?(new_path) + return false unless File.directory?(new_path) + + found = Dir.glob(File.join(new_path, '**', '**')) + + (found - discardable_paths(new_path)).empty? + end + + private + + def discardable_paths(new_path) + DISCARDABLE_PATHS.collect { |path| File.join(new_path, path) } + end + + def find_old_attachments_path(project) + origin = FileUploader.absolute_base_dir(project) + + # It's possible that old_disk_path does not match project.disk_path. + # For example, that happens when we rename a project + # + origin.sub(/#{Regexp.escape(project.full_path)}\z/, old_disk_path) + end end end end diff --git a/app/services/projects/hashed_storage/migration_service.rb b/app/services/projects/hashed_storage/migration_service.rb index f132dca61c9..57a775a8f9e 100644 --- a/app/services/projects/hashed_storage/migration_service.rb +++ b/app/services/projects/hashed_storage/migration_service.rb @@ -14,12 +14,12 @@ module Projects def execute # Migrate repository from Legacy to Hashed Storage unless project.hashed_storage?(:repository) - return false unless migrate_repository + return false unless migrate_repository_service.execute end # Migrate attachments from Legacy to Hashed Storage unless project.hashed_storage?(:attachments) - return false unless migrate_attachments + return false unless migrate_attachments_service.execute end true @@ -27,12 +27,12 @@ module Projects private - def migrate_repository - HashedStorage::MigrateRepositoryService.new(project, old_disk_path, logger: logger).execute + def migrate_repository_service + HashedStorage::MigrateRepositoryService.new(project: project, old_disk_path: old_disk_path, logger: logger) end - def migrate_attachments - HashedStorage::MigrateAttachmentsService.new(project, old_disk_path, logger: logger).execute + def migrate_attachments_service + HashedStorage::MigrateAttachmentsService.new(project: project, old_disk_path: old_disk_path, logger: logger) end end end diff --git a/app/services/projects/hashed_storage/rollback_attachments_service.rb b/app/services/projects/hashed_storage/rollback_attachments_service.rb index fb09eaa4586..4bb8cb605a3 100644 --- a/app/services/projects/hashed_storage/rollback_attachments_service.rb +++ b/app/services/projects/hashed_storage/rollback_attachments_service.rb @@ -3,14 +3,9 @@ module Projects module HashedStorage class RollbackAttachmentsService < BaseAttachmentService - def initialize(project, logger: nil) - @project = project - @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger - @old_disk_path = project.disk_path - end - def execute origin = FileUploader.absolute_base_dir(project) + project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository] target = FileUploader.absolute_base_dir(project) diff --git a/app/services/projects/hashed_storage/rollback_service.rb b/app/services/projects/hashed_storage/rollback_service.rb index ee41aae64a5..c437001c440 100644 --- a/app/services/projects/hashed_storage/rollback_service.rb +++ b/app/services/projects/hashed_storage/rollback_service.rb @@ -5,32 +5,26 @@ module Projects class RollbackService < BaseService attr_reader :logger, :old_disk_path - def initialize(project, old_disk_path, logger: nil) - @project = project - @old_disk_path = old_disk_path - @logger = logger || Rails.logger # rubocop:disable Gitlab/RailsLogger - end - def execute # Rollback attachments from Hashed Storage to Legacy if project.hashed_storage?(:attachments) - return false unless rollback_attachments + return false unless rollback_attachments_service.execute end # Rollback repository from Hashed Storage to Legacy if project.hashed_storage?(:repository) - rollback_repository + rollback_repository_service.execute end end private - def rollback_attachments - HashedStorage::RollbackAttachmentsService.new(project, logger: logger).execute + def rollback_attachments_service + HashedStorage::RollbackAttachmentsService.new(project: project, old_disk_path: old_disk_path, logger: logger) end - def rollback_repository - HashedStorage::RollbackRepositoryService.new(project, old_disk_path, logger: logger).execute + def rollback_repository_service + HashedStorage::RollbackRepositoryService.new(project: project, old_disk_path: old_disk_path, logger: logger) end end end diff --git a/app/services/projects/import_export/export_service.rb b/app/services/projects/import_export/export_service.rb index d3638c57552..8344397f67d 100644 --- a/app/services/projects/import_export/export_service.rb +++ b/app/services/projects/import_export/export_service.rb @@ -24,7 +24,7 @@ module Projects def save_all! if save_exporters - Gitlab::ImportExport::Saver.save(project: project, shared: shared) + Gitlab::ImportExport::Saver.save(exportable: project, shared: shared) notify_success else cleanup_and_notify_error! diff --git a/app/services/projects/lfs_pointers/lfs_link_service.rb b/app/services/projects/lfs_pointers/lfs_link_service.rb index 38de2af9c1e..a05c76f5e85 100644 --- a/app/services/projects/lfs_pointers/lfs_link_service.rb +++ b/app/services/projects/lfs_pointers/lfs_link_service.rb @@ -4,6 +4,9 @@ module Projects module LfsPointers class LfsLinkService < BaseService + TooManyOidsError = Class.new(StandardError) + + MAX_OIDS = 100_000 BATCH_SIZE = 1000 # Accept an array of oids to link @@ -12,6 +15,10 @@ module Projects def execute(oids) return [] unless project&.lfs_enabled? + if oids.size > MAX_OIDS + raise TooManyOidsError, 'Too many LFS object ids to link, please push them manually' + end + # Search and link existing LFS Object link_existing_lfs_objects(oids) end @@ -20,22 +27,27 @@ module Projects # rubocop: disable CodeReuse/ActiveRecord def link_existing_lfs_objects(oids) - all_existing_objects = [] + linked_existing_objects = [] iterations = 0 - LfsObject.where(oid: oids).each_batch(of: BATCH_SIZE) do |existent_lfs_objects| + oids.each_slice(BATCH_SIZE) do |oids_batch| + # Load all existing LFS Objects immediately so we don't issue an extra + # query for the `.any?` + existent_lfs_objects = LfsObject.where(oid: oids_batch).load next unless existent_lfs_objects.any? + rows = existent_lfs_objects + .not_linked_to_project(project) + .map { |existing_lfs_object| { project_id: project.id, lfs_object_id: existing_lfs_object.id } } + Gitlab::Database.bulk_insert(:lfs_objects_projects, rows) iterations += 1 - not_linked_lfs_objects = existent_lfs_objects.where.not(id: project.all_lfs_objects) - project.all_lfs_objects << not_linked_lfs_objects - all_existing_objects += existent_lfs_objects.pluck(:oid) + linked_existing_objects += existent_lfs_objects.map(&:oid) end - log_lfs_link_results(all_existing_objects.count, iterations) + log_lfs_link_results(linked_existing_objects.count, iterations) - all_existing_objects + linked_existing_objects end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 525fc18b312..718416a03d4 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -13,6 +13,8 @@ module Projects include Gitlab::ShellAdapter TransferError = Class.new(StandardError) + attr_reader :new_namespace + def execute(new_namespace) @new_namespace = new_namespace diff --git a/app/services/quick_actions/target_service.rb b/app/services/quick_actions/target_service.rb index 69464c3c1ae..4273acfbf8b 100644 --- a/app/services/quick_actions/target_service.rb +++ b/app/services/quick_actions/target_service.rb @@ -32,3 +32,5 @@ module QuickActions end end end + +QuickActions::TargetService.prepend_if_ee('EE::QuickActions::TargetService') diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index b3eee01ea7a..25e3282d3fb 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -128,82 +128,37 @@ module SystemNoteService # Called when 'merge when pipeline succeeds' is executed def merge_when_pipeline_succeeds(noteable, project, author, sha) - body = "enabled an automatic merge when the pipeline for #{sha} succeeds" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).merge_when_pipeline_succeeds(sha) end # Called when 'merge when pipeline succeeds' is canceled def cancel_merge_when_pipeline_succeeds(noteable, project, author) - body = 'canceled the automatic merge' - - create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).cancel_merge_when_pipeline_succeeds end # Called when 'merge when pipeline succeeds' is aborted def abort_merge_when_pipeline_succeeds(noteable, project, author, reason) - body = "aborted the automatic merge because #{reason}" - - ## - # TODO: Abort message should be sent by the system, not a particular user. - # See https://gitlab.com/gitlab-org/gitlab-foss/issues/63187. - create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).abort_merge_when_pipeline_succeeds(reason) end def handle_merge_request_wip(noteable, project, author) - prefix = noteable.work_in_progress? ? "marked" : "unmarked" - - body = "#{prefix} as a **Work In Progress**" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).handle_merge_request_wip end def add_merge_request_wip_from_commit(noteable, project, author, commit) - body = "marked as a **Work In Progress** from #{commit.to_reference(project)}" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).add_merge_request_wip_from_commit(commit) end def resolve_all_discussions(merge_request, project, author) - body = "resolved all threads" - - create_note(NoteSummary.new(merge_request, project, author, body, action: 'discussion')) + ::SystemNotes::MergeRequestsService.new(noteable: merge_request, project: project, author: author).resolve_all_discussions end def discussion_continued_in_issue(discussion, project, author, issue) - body = "created #{issue.to_reference} to continue this discussion" - note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - - note = Note.create(note_attributes.merge(system: true, created_at: issue.system_note_timestamp)) - note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') - - note + ::SystemNotes::MergeRequestsService.new(project: project, author: author).discussion_continued_in_issue(discussion, issue) end def diff_discussion_outdated(discussion, project, author, change_position) - merge_request = discussion.noteable - diff_refs = change_position.diff_refs - version_index = merge_request.merge_request_diffs.viewable.count - position_on_text = change_position.on_text? - text_parts = ["changed this #{position_on_text ? 'line' : 'file'} in"] - - if version_params = merge_request.version_params_for(diff_refs) - repository = project.repository - anchor = position_on_text ? change_position.line_code(repository) : change_position.file_hash - url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: anchor)) - - text_parts << "[version #{version_index} of the diff](#{url})" - else - text_parts << "version #{version_index} of the diff" - end - - body = text_parts.join(' ') - note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) - - note = Note.create(note_attributes.merge(system: true)) - note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated') - - note + ::SystemNotes::MergeRequestsService.new(project: project, author: author).diff_discussion_outdated(discussion, change_position) end def change_title(noteable, project, author, old_title) @@ -233,9 +188,7 @@ module SystemNoteService # # Returns the created Note object def change_branch(noteable, project, author, branch_type, old_branch, new_branch) - body = "changed #{branch_type} branch from `#{old_branch}` to `#{new_branch}`" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).change_branch(branch_type, old_branch, new_branch) end # Called when a branch in Noteable is added or deleted @@ -253,16 +206,7 @@ module SystemNoteService # # Returns the created Note object def change_branch_presence(noteable, project, author, branch_type, branch, presence) - verb = - if presence == :add - 'restored' - else - 'deleted' - end - - body = "#{verb} #{branch_type} branch `#{branch}`" - - create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + ::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).change_branch_presence(branch_type, branch, presence) end # Called when a branch is created from the 'new branch' button on a issue @@ -270,18 +214,11 @@ module SystemNoteService # # "created branch `201-issue-branch-button`" def new_issue_branch(issue, project, author, branch, branch_project: nil) - branch_project ||= project - link = url_helpers.project_compare_path(branch_project, from: branch_project.default_branch, to: branch) - - body = "created branch [`#{branch}`](#{link}) to address this issue" - - create_note(NoteSummary.new(issue, project, author, body, action: 'branch')) + ::SystemNotes::MergeRequestsService.new(noteable: issue, project: project, author: author).new_issue_branch(branch, branch_project: branch_project) end def new_merge_request(issue, project, author, merge_request) - body = "created merge request #{merge_request.to_reference(project)} to address this issue" - - create_note(NoteSummary.new(issue, project, author, body, action: 'merge')) + ::SystemNotes::MergeRequestsService.new(noteable: issue, project: project, author: author).new_merge_request(merge_request) end def cross_reference(noteable, mentioner, author) diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb new file mode 100644 index 00000000000..1d17f0ded57 --- /dev/null +++ b/app/services/system_notes/merge_requests_service.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +module SystemNotes + class MergeRequestsService < ::SystemNotes::BaseService + # Called when 'merge when pipeline succeeds' is executed + def merge_when_pipeline_succeeds(sha) + body = "enabled an automatic merge when the pipeline for #{sha} succeeds" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + + # Called when 'merge when pipeline succeeds' is canceled + def cancel_merge_when_pipeline_succeeds + body = 'canceled the automatic merge' + + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + + # Called when 'merge when pipeline succeeds' is aborted + def abort_merge_when_pipeline_succeeds(reason) + body = "aborted the automatic merge because #{reason}" + + ## + # TODO: Abort message should be sent by the system, not a particular user. + # See https://gitlab.com/gitlab-org/gitlab-foss/issues/63187. + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + + def handle_merge_request_wip + prefix = noteable.work_in_progress? ? "marked" : "unmarked" + + body = "#{prefix} as a **Work In Progress**" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + end + + def add_merge_request_wip_from_commit(commit) + body = "marked as a **Work In Progress** from #{commit.to_reference(project)}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'title')) + end + + def resolve_all_discussions + body = "resolved all threads" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'discussion')) + end + + def discussion_continued_in_issue(discussion, issue) + body = "created #{issue.to_reference} to continue this discussion" + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) + + Note.create(note_attributes.merge(system: true, created_at: issue.system_note_timestamp)).tap do |note| + note.system_note_metadata = SystemNoteMetadata.new(action: 'discussion') + end + end + + def diff_discussion_outdated(discussion, change_position) + merge_request = discussion.noteable + diff_refs = change_position.diff_refs + version_index = merge_request.merge_request_diffs.viewable.count + position_on_text = change_position.on_text? + text_parts = ["changed this #{position_on_text ? 'line' : 'file'} in"] + + if version_params = merge_request.version_params_for(diff_refs) + repository = project.repository + anchor = position_on_text ? change_position.line_code(repository) : change_position.file_hash + url = url_helpers.diffs_project_merge_request_path(project, merge_request, version_params.merge(anchor: anchor)) + + text_parts << "[version #{version_index} of the diff](#{url})" + else + text_parts << "version #{version_index} of the diff" + end + + body = text_parts.join(' ') + note_attributes = discussion.reply_attributes.merge(project: project, author: author, note: body) + + Note.create(note_attributes.merge(system: true)).tap do |note| + note.system_note_metadata = SystemNoteMetadata.new(action: 'outdated') + end + end + + # Called when a branch in Noteable is changed + # + # branch_type - 'source' or 'target' + # old_branch - old branch name + # new_branch - new branch name + # + # Example Note text: + # + # "changed target branch from `Old` to `New`" + # + # Returns the created Note object + def change_branch(branch_type, old_branch, new_branch) + body = "changed #{branch_type} branch from `#{old_branch}` to `#{new_branch}`" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + end + + # Called when a branch in Noteable is added or deleted + # + # branch_type - :source or :target + # branch - branch name + # presence - :add or :delete + # + # Example Note text: + # + # "restored target branch `feature`" + # + # Returns the created Note object + def change_branch_presence(branch_type, branch, presence) + verb = + if presence == :add + 'restored' + else + 'deleted' + end + + body = "#{verb} #{branch_type} branch `#{branch}`" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + end + + # Called when a branch is created from the 'new branch' button on a issue + # Example note text: + # + # "created branch `201-issue-branch-button`" + def new_issue_branch(branch, branch_project: nil) + branch_project ||= project + link = url_helpers.project_compare_path(branch_project, from: branch_project.default_branch, to: branch) + + body = "created branch [`#{branch}`](#{link}) to address this issue" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'branch')) + end + + def new_merge_request(merge_request) + body = "created merge request #{merge_request.to_reference(project)} to address this issue" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'merge')) + end + end +end + +SystemNotes::MergeRequestsService.prepend_if_ee('::EE::SystemNotes::MergeRequestsService') diff --git a/app/services/users/signup_service.rb b/app/services/users/signup_service.rb new file mode 100644 index 00000000000..1031cec44cb --- /dev/null +++ b/app/services/users/signup_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Users + class SignupService < BaseService + def initialize(current_user, params = {}) + @user = current_user + @params = params.dup + end + + def execute + assign_attributes + inject_validators + + if @user.save + success + else + error(@user.errors.full_messages.join('. ')) + end + end + + private + + def assign_attributes + @user.assign_attributes(params) unless params.empty? + end + + def inject_validators + class << @user + validates :role, presence: true + validates :setup_for_company, inclusion: { in: [true, false], message: :blank } + end + end + end +end diff --git a/app/services/zoom_notes_service.rb b/app/services/zoom_notes_service.rb deleted file mode 100644 index 983a7fcacd1..00000000000 --- a/app/services/zoom_notes_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -class ZoomNotesService - def initialize(issue, project, current_user, old_description: nil) - @issue = issue - @project = project - @current_user = current_user - @old_description = old_description - end - - def execute - return if @issue.description == @old_description - - if zoom_link_added? - zoom_link_added_notification - elsif zoom_link_removed? - zoom_link_removed_notification - end - end - - private - - def zoom_link_added? - has_zoom_link?(@issue.description) && !has_zoom_link?(@old_description) - end - - def zoom_link_removed? - !has_zoom_link?(@issue.description) && has_zoom_link?(@old_description) - end - - def has_zoom_link?(text) - Gitlab::ZoomLinkExtractor.new(text).match? - end - - def zoom_link_added_notification - SystemNoteService.zoom_link_added(@issue, @project, @current_user) - end - - def zoom_link_removed_notification - SystemNoteService.zoom_link_removed(@issue, @project, @current_user) - end -end diff --git a/app/validators/same_project_association_validator.rb b/app/validators/same_project_association_validator.rb new file mode 100644 index 00000000000..2af2a21fa9a --- /dev/null +++ b/app/validators/same_project_association_validator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# SameProjectAssociationValidator +# +# Custom validator to validate that the same project associated with +# the record is also associated with the value +# +# Example: +# class ZoomMeeting < ApplicationRecord +# belongs_to :project, optional: false +# belongs_to :issue, optional: false + +# validates :issue, same_project_association: true +# end +class SameProjectAssociationValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if record.project == value&.project + + record.errors[attribute] << 'must associate the same project' + end +end diff --git a/app/validators/zoom_url_validator.rb b/app/validators/zoom_url_validator.rb new file mode 100644 index 00000000000..dc4ca6b9501 --- /dev/null +++ b/app/validators/zoom_url_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# ZoomUrlValidator +# +# Custom validator for zoom urls +# +class ZoomUrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if Gitlab::ZoomLinkExtractor.new(value).links.size == 1 + + record.errors.add(:url, 'must contain one valid Zoom URL') + end +end diff --git a/app/views/admin/abuse_reports/index.html.haml b/app/views/admin/abuse_reports/index.html.haml index cc29657a439..e3d78b3058f 100644 --- a/app/views/admin/abuse_reports/index.html.haml +++ b/app/views/admin/abuse_reports/index.html.haml @@ -1,6 +1,18 @@ -- page_title 'Abuse Reports' -%h3.page-title Abuse Reports -%hr +- page_title _('Abuse Reports') + +%h3.page-title= _('Abuse Reports') + +.row-content-block.second-block + = form_tag admin_abuse_reports_path, method: :get, class: 'filter-form' do + .filter-categories.flex-fill + .filter-item.inline + = dropdown_tag(user_dropdown_label(params[:user_id], 'User'), + options: { toggle_class: 'js-filter-submit js-user-search', + title: _('Filter by user'), filter: true, filterInput: 'input#user-search', + dropdown_class: 'dropdown-menu-selectable dropdown-menu-user js-filter-submit', + placeholder: _('Search users'), + data: { current_user: true, field_name: 'user_id' }}) + .abuse-reports - if @abuse_reports.present? .table-holder diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 1f5bce19bc6..9806090c1a6 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -53,5 +53,11 @@ = s_('AdminSettings|Environment variables are protected by default') .form-text.text-muted = s_('AdminSettings|When creating a new environment variable it will be protected by default.') + .form-group + = f.label :ci_config_path, _('Default CI configuration path'), class: 'label-bold' + = f.text_field :default_ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' + %p.form-text.text-muted + = _("The default CI configuration path for new projects.").html_safe + = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'custom-ci-config-path'), target: '_blank' = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml new file mode 100644 index 00000000000..b1f7ed76281 --- /dev/null +++ b/app/views/admin/application_settings/_eks.html.haml @@ -0,0 +1,31 @@ +- expanded = integration_expanded?('eks_') +%section.settings.as-eks.no-animate#js-eks-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Amazon EKS') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('Amazon EKS integration allows you to provision EKS clusters from GitLab.') + + .settings-content + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :eks_integration_enabled, class: 'form-check-input' + = f.label :eks_integration_enabled, class: 'form-check-label' do + Enable Amazon EKS integration + .form-group + = f.label :eks_account_id, 'Account ID', class: 'label-bold' + = f.text_field :eks_account_id, class: 'form-control' + .form-group + = f.label :eks_access_key_id, 'Access key ID', class: 'label-bold' + = f.text_field :eks_access_key_id, class: 'form-control' + .form-group + = f.label :eks_secret_access_key, 'Secret access key', class: 'label-bold' + = f.password_field :eks_secret_access_key, value: @application_setting.eks_secret_access_key, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index ad26f52aea7..42528f40123 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group .form-check - = f.check_box :allow_local_requests_from_web_hooks_and_services, class: 'form-check-input' + = f.check_box :allow_local_requests_from_web_hooks_and_services, class: 'form-check-input', data: { qa_selector: 'allow_requests_from_services_checkbox' } = f.label :allow_local_requests_from_web_hooks_and_services, class: 'form-check-label' do = _('Allow requests to the local network from web hooks and services') .form-check @@ -27,4 +27,4 @@ %span.form-text.text-muted = _('Resolves IP addresses once and uses them to submit requests') - = f.submit 'Save changes', class: "btn btn-success" + = f.submit 'Save changes', class: "btn btn-success", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index 86dc289dd7c..d35774d330d 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -1,18 +1,27 @@ -= form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) +- expanded = integration_expanded?('plantuml_') +%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('PlantUML') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') + .settings-content + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) if expanded - %fieldset - .form-group - .form-check - = f.check_box :plantuml_enabled, class: 'form-check-input' - = f.label :plantuml_enabled, class: 'form-check-label' do - Enable PlantUML - .form-group - = f.label :plantuml_url, 'PlantUML URL', class: 'label-bold' - = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' - .form-text.text-muted - Allow rendering of - = link_to "PlantUML", "http://plantuml.com" - diagrams in Asciidoc documents using an external PlantUML service. + %fieldset + .form-group + .form-check + = f.check_box :plantuml_enabled, class: 'form-check-input' + = f.label :plantuml_enabled, _('Enable PlantUML'), class: 'form-check-label' + .form-group + = f.label :plantuml_url, 'PlantUML URL', class: 'label-bold' + = f.text_field :plantuml_url, class: 'form-control', placeholder: 'http://gitlab.your-plantuml-instance.com:8080' + .form-text.text-muted + Allow rendering of + = link_to "PlantUML", "http://plantuml.com" + diagrams in Asciidoc documents using an external PlantUML service. - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index 362f4a42464..6e5fa6eb62c 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -7,9 +7,9 @@ .form-check = f.check_box :mirror_available, class: 'form-check-input' = f.label :mirror_available, class: 'form-check-label' do - = _('Allow mirrors to be set up for projects') + = _('Allow repository mirroring to be configured by project maintainers') %span.form-text.text-muted - = _('If disabled, only admins will be able to set up mirrors in projects.') + = _('If disabled, only admins will be able to configure repository mirroring.') = link_to icon('question-circle'), help_page_path('workflow/repository_mirroring') = render_if_exists 'admin/application_settings/mirror_settings', form: f diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index 31fd12d191e..a2597433270 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -1,4 +1,4 @@ -- expanded = true if !@application_setting.valid? && @application_setting.errors.any? { |k| k.to_s.start_with?('snowplow_') } +- expanded = integration_expanded?('snowplow_') %section.settings.as-snowplow.no-animate#js-snowplow-settings{ class: ('expanded' if expanded) } .settings-header %h4 @@ -10,7 +10,7 @@ .settings-content = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(@application_setting) + = form_errors(@application_setting) if expanded %fieldset .form-group @@ -21,10 +21,13 @@ = f.label :snowplow_collector_hostname, _('Collector hostname'), class: 'label-light' = f.text_field :snowplow_collector_hostname, class: 'form-control', placeholder: 'snowplow.example.com' .form-group - = f.label :snowplow_site_id, _('Site ID'), class: 'label-light' - = f.text_field :snowplow_site_id, class: 'form-control' + = f.label :snowplow_app_id, _('App ID'), class: 'label-light' + = f.text_field :snowplow_app_id, class: 'form-control' .form-group = f.label :snowplow_cookie_domain, _('Cookie domain'), class: 'label-light' = f.text_field :snowplow_cookie_domain, class: 'form-control' + .form-group + = f.label :snowplow_iglu_registry_url, _('Iglu registry URL (optional)'), class: 'label-light' + = f.text_field :snowplow_iglu_registry_url, class: 'form-control' = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml new file mode 100644 index 00000000000..23cda0334a2 --- /dev/null +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -0,0 +1,38 @@ +- return unless Gitlab::Sourcegraph.feature_available? +- expanded = integration_expanded?('sourcegraph_') + +%section.settings.as-sourcegraph.no-animate#js-sourcegraph-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Sourcegraph') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: 'https://sourcegraph.com/' } + - link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe + = s_('SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance\'s code views and merge requests.').html_safe % { link_start: link_start, link_end: link_end } + %span + = link_to s_('SourcegraphAdmin|More information'), help_page_path('integration/sourcegraph.md'), target: '_blank' + + + .settings-content + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :sourcegraph_enabled, class: 'form-check-input' + = f.label :sourcegraph_enabled, s_('SourcegraphAdmin|Enable Sourcegraph'), class: 'form-check-label' + .form-group + .form-check + = f.check_box :sourcegraph_public_only, class: 'form-check-input' + = f.label :sourcegraph_public_only, s_('SourcegraphAdmin|Block on private and internal projects'), class: 'form-check-label' + .form-text.text-muted + = s_('SourcegraphAdmin|If checked, only public projects will have code intelligence and communicate with Sourcegraph.') + .form-group + = f.label :sourcegraph_url, s_('SourcegraphAdmin|Sourcegraph URL'), class: 'label-bold' + = f.text_field :sourcegraph_url, class: 'form-control', placeholder: s_('SourcegraphAdmin|e.g. https://sourcegraph.example.com') + .form-text.text-muted + = s_('SourcegraphAdmin|Configure the URL to a Sourcegraph instance which can read your GitLab projects.') + = f.submit s_('SourcegraphAdmin|Save changes'), class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml index adde09f75e4..256b1f74bfa 100644 --- a/app/views/admin/application_settings/_third_party_offers.html.haml +++ b/app/views/admin/application_settings/_third_party_offers.html.haml @@ -1,13 +1,21 @@ -- application_setting = local_assigns.fetch(:application_setting) +- expanded = integration_expanded?('hide_third_party_') +%section.settings.as-third-party-offers.no-animate#js-third-party-offers-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Third party offers') + %button.btn.btn-default.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Control the display of third party offers.') + .settings-content -= form_for application_setting, url: integrations_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form' } do |f| - = form_errors(application_setting) + = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) if expanded - %fieldset - .form-group - .form-check - = f.check_box :hide_third_party_offers, class: 'form-check-input' - = f.label :hide_third_party_offers, class: 'form-check-label' do - Do not display offers from third parties within GitLab + %fieldset + .form-group + .form-check + = f.check_box :hide_third_party_offers, class: 'form-check-input' + = f.label :hide_third_party_offers, _('Do not display offers from third parties within GitLab'), class: 'form-check-label' - = f.submit 'Save changes', class: "btn btn-success" + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index 310e86b1377..0aa833e49a8 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -2,30 +2,11 @@ - page_title _("Integrations") - @content_class = "limit-container-width" unless fluid_layout -= render_if_exists 'admin/application_settings/elasticsearch_form', expanded: expanded_by_default? += render_if_exists 'admin/application_settings/elasticsearch_form' += render 'admin/application_settings/plantuml' += render 'admin/application_settings/sourcegraph' += render_if_exists 'admin/application_settings/slack' += render 'admin/application_settings/third_party_offers' += render 'admin/application_settings/snowplow' += render 'admin/application_settings/eks' if Feature.enabled?(:create_eks_clusters) -%section.settings.as-plantuml.no-animate#js-plantuml-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('PlantUML') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') - .settings-content - = render 'plantuml' - -= render_if_exists 'admin/application_settings/slack', expanded: expanded_by_default? - -%section.settings.as-third-party-offers.no-animate#js-third-party-offers-settings{ class: ('expanded' if expanded_by_default?) } - .settings-header - %h4 - = _('Third party offers') - %button.btn.btn-default.js-settings-toggle{ type: 'button' } - = expanded_by_default? ? _('Collapse') : _('Expand') - %p - = _('Control the display of third party offers.') - .settings-content - = render 'third_party_offers', application_setting: @application_setting - -= render_if_exists 'admin/application_settings/snowplow', expanded: expanded_by_default? diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 092834b993c..7bd51172195 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -24,7 +24,7 @@ .settings-content = render 'ip_limits' -%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_section' } } .settings-header %h4 = _('Outbound requests') diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 25f8b6541b5..b0934a9d9fb 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -5,11 +5,11 @@ %section.settings.as-mirror.no-animate#js-mirror-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 - = _('Repository mirror') + = _('Repository mirroring') %button.btn.js-settings-toggle{ type: 'button' } = expanded_by_default? ? 'Collapse' : 'Expand' %p - = _('Configure push mirrors.') + = _('Configure repository mirroring.') .settings-content = render partial: 'repository_mirrors_form' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 41147950c40..e5a3c0df9bf 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -41,17 +41,38 @@ .info-well .well-segment.admin-well.admin-well-features %h4 Features - = feature_entry(_('Sign up'), href: admin_application_settings_path(anchor: 'js-signup-settings'), enabled: allow_signup?) - = feature_entry(_('LDAP'), enabled: Gitlab.config.ldap.enabled) - = feature_entry(_('Gravatar'), href: admin_application_settings_path(anchor: 'js-account-settings'), enabled: gravatar_enabled?) - = feature_entry(_('OmniAuth'), href: admin_application_settings_path(anchor: 'js-signin-settings'), enabled: Gitlab::Auth.omniauth_enabled?) - = feature_entry(_('Reply by email'), enabled: Gitlab::IncomingEmail.enabled?) + = feature_entry(_('Sign up'), + href: admin_application_settings_path(anchor: 'js-signup-settings'), + enabled: allow_signup?) + + = feature_entry(_('LDAP'), + enabled: Gitlab.config.ldap.enabled) + + = feature_entry(_('Gravatar'), + href: admin_application_settings_path(anchor: 'js-account-settings'), + enabled: gravatar_enabled?) + + = feature_entry(_('OmniAuth'), + href: admin_application_settings_path(anchor: 'js-signin-settings'), + enabled: Gitlab::Auth.omniauth_enabled?) + + = feature_entry(_('Reply by email'), + enabled: Gitlab::IncomingEmail.enabled?) = render_if_exists 'admin/dashboard/elastic_and_geo' - = feature_entry(_('Container Registry'), href: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), enabled: Gitlab.config.registry.enabled) - = feature_entry(_('Gitlab Pages'), href: help_instance_configuration_url, enabled: Gitlab.config.pages.enabled) - = feature_entry(_('Shared Runners'), href: admin_runners_path, enabled: Gitlab.config.gitlab_ci.shared_runners_enabled) + = feature_entry(_('Container Registry'), + href: ci_cd_admin_application_settings_path(anchor: 'js-registry-settings'), + enabled: Gitlab.config.registry.enabled, + doc_href: help_page_path('user/packages/container_registry/index')) + + = feature_entry(_('Gitlab Pages'), + enabled: Gitlab.config.pages.enabled, + doc_href: help_instance_configuration_url) + + = feature_entry(_('Shared Runners'), + href: admin_runners_path, + enabled: Gitlab.config.gitlab_ci.shared_runners_enabled) .col-md-4 .info-well .well-segment.admin-well diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 55aea0296e7..3d77a439d61 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -4,4 +4,4 @@ = password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } .submit-container.move-submit-down - = submit_tag _('Enter admin mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' } + = submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'sign_in_button' } diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml index 69baa76060e..1d19915d3c5 100644 --- a/app/views/admin/sessions/_signin_box.html.haml +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -1,4 +1,4 @@ -- if form_based_providers.any? +- if any_form_based_providers_enabled? - if password_authentication_enabled_for_web? .login-box.tab-pane{ id: 'login-pane', role: 'tabpanel' } diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml index f5dedb5ad76..20830051d31 100644 --- a/app/views/admin/sessions/_tabs_normal.html.haml +++ b/app/views/admin/sessions/_tabs_normal.html.haml @@ -1,3 +1,3 @@ %ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' } %li.nav-item{ role: 'presentation' } - %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter admin mode') + %a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter Admin Mode') diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index ee06b4a1741..73028e78ea5 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -1,5 +1,5 @@ - @hide_breadcrumbs = true -- page_title _('Enter admin mode') +- page_title _('Enter Admin Mode') .row.justify-content-center .col-6.new-session-forms-container diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 706fa033c51..cd07fee8e59 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -152,7 +152,7 @@ - email = " (#{@user.unconfirmed_email})" %p This user has an unconfirmed email address#{email}. You may force a confirmation. %br - = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?' } + = link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' } = render_if_exists 'admin/users/user_detail_note' diff --git a/app/views/ci/group_variables/_content.html.haml b/app/views/ci/group_variables/_content.html.haml new file mode 100644 index 00000000000..db5f1021f57 --- /dev/null +++ b/app/views/ci/group_variables/_content.html.haml @@ -0,0 +1 @@ += _("These variables are configured in the parent group settings, and will be active in the current project in addition to the project variables.") diff --git a/app/views/ci/group_variables/_header.html.haml b/app/views/ci/group_variables/_header.html.haml new file mode 100644 index 00000000000..71d123ec9f2 --- /dev/null +++ b/app/views/ci/group_variables/_header.html.haml @@ -0,0 +1,5 @@ +%h5 + = _('Group variables (inherited)') + +%p + = render "ci/group_variables/content" diff --git a/app/views/ci/group_variables/_index.html.haml b/app/views/ci/group_variables/_index.html.haml new file mode 100644 index 00000000000..c350ba5caf7 --- /dev/null +++ b/app/views/ci/group_variables/_index.html.haml @@ -0,0 +1,13 @@ +- variables = @project.group.self_and_ancestors.map(&:variables).flatten + +.row + .col-lg-12 + .group-variable-list + = render 'ci/group_variables/variable_header' + - variables.each do |variable| + .group-variable-row.d-flex.w-100.border-bottom.pt-2.pb-2 + .table-section.section-40.append-right-10.key + = variable.key + .table-section.section-40.append-right-10 + %a.group-origin-link{ href: group_settings_ci_cd_path(variable.group) } + = variable.group.name diff --git a/app/views/ci/group_variables/_variable_header.html.haml b/app/views/ci/group_variables/_variable_header.html.haml new file mode 100644 index 00000000000..1a3168cf781 --- /dev/null +++ b/app/views/ci/group_variables/_variable_header.html.haml @@ -0,0 +1,5 @@ +.group-variable-keys.d-flex.w-100.align-items-center.pb-2.border-bottom + .bold.table-section.section-40.append-right-10 + = s_('Key') + .bold.table-section.section-40.append-right-10 + = s_('Origin') diff --git a/app/views/ci/variables/_header.html.haml b/app/views/ci/variables/_header.html.haml index dbfa0a9e5a1..ce4dd5a4877 100644 --- a/app/views/ci/variables/_header.html.haml +++ b/app/views/ci/variables/_header.html.haml @@ -7,5 +7,5 @@ %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') -%p.append-bottom-0 +%p = render "ci/variables/content" diff --git a/app/views/ci/variables/_index.html.haml b/app/views/ci/variables/_index.html.haml index 94102b4dcd0..7ae5c48b93c 100644 --- a/app/views/ci/variables/_index.html.haml +++ b/app/views/ci/variables/_index.html.haml @@ -24,3 +24,8 @@ = n_('Hide value', 'Hide values', @variables.size) - else = n_('Reveal value', 'Reveal values', @variables.size) + - if !@group && @project.group + .settings-header.border-top.prepend-top-20 + = render 'ci/group_variables/header' + .settings-content.pr-0 + = render 'ci/group_variables/index' diff --git a/app/views/ci/variables/_url_query_variable_row.html.haml b/app/views/ci/variables/_url_query_variable_row.html.haml new file mode 100644 index 00000000000..6672a8e5ea0 --- /dev/null +++ b/app/views/ci/variables/_url_query_variable_row.html.haml @@ -0,0 +1,28 @@ +- form_field = local_assigns.fetch(:form_field, nil) +- variable = local_assigns.fetch(:variable, nil) + +- key = variable[0] +- value = variable[1] +- variable_type = variable[2] || "env_var" + +- destroy_input_name = "#{form_field}[variables_attributes][][_destroy]" +- variable_type_input_name = "#{form_field}[variables_attributes][][variable_type]" +- key_input_name = "#{form_field}[variables_attributes][][key]" +- value_input_name = "#{form_field}[variables_attributes][][secret_value]" + +%li.js-row.ci-variable-row + .ci-variable-row-body.border-bottom + %input.js-ci-variable-input-destroy{ type: "hidden", name: destroy_input_name } + %select.js-ci-variable-input-variable-type.ci-variable-body-item.form-control.select-control.custom-select.table-section.section-15{ name: variable_type_input_name } + = options_for_select(ci_variable_type_options, variable_type) + %input.js-ci-variable-input-key.ci-variable-body-item.qa-ci-variable-input-key.form-control.table-section.section-15{ type: "text", + name: key_input_name, + value: key, + placeholder: s_('CiVariables|Input variable key') } + .ci-variable-body-item.gl-show-field-errors.table-section.section-15.border-top-0.p-0 + %textarea.js-ci-variable-input-value.js-secret-value.qa-ci-variable-input-value.form-control{ rows: 1, + name: value_input_name, + placeholder: s_('CiVariables|Input variable value') } + = value + %button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } + = icon('minus-circle') diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index 8005dcbf65f..493d7a00854 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -1,3 +1,9 @@ +- group_id = @cluster.group.id if @cluster.group_type? + +- if @cluster.project_type? + - group_id = @cluster.project.group.id if @cluster.project.group + - user_id = @cluster.project.namespace.owner_id unless group_id + - if can?(current_user, :admin_cluster, @cluster) - unless @cluster.provided_by_user? .append-bottom-20 @@ -7,6 +13,21 @@ - link_gke = link_to(s_('ClusterIntegration|Google Kubernetes Engine'), @cluster.gke_cluster_url, target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{link_gke}').html_safe % { link_gke: link_gke } + = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field| + + %h5 + = s_('ClusterIntegration|Cluster management project (alpha)') + + .form-group + .form-text.text-muted + = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', + placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id) + .text-muted + = s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges.').html_safe + = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank' + .form-group + = field.submit _('Save changes'), class: 'btn btn-success qa-save-domain' + .sub-section.form-group %h4.text-danger = s_('ClusterIntegration|Remove Kubernetes cluster integration') diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index 4b4278075a6..7d97aaccbcf 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -1,10 +1,10 @@ .hidden.js-cluster-error.bs-callout.bs-callout-danger{ role: 'alert' } - = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster on Google Kubernetes Engine') + = s_('ClusterIntegration|Something went wrong while creating your Kubernetes cluster') %p.js-error-reason .hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' } %span.spinner.spinner-dark.spinner-sm{ 'aria-label': 'Loading' } - %span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...') + %span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created...') .hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' } .col-11 @@ -19,4 +19,4 @@ %button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×" .hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' } - = s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine.") + = s_("ClusterIntegration|Kubernetes cluster was successfully created.") diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index a9299af8d78..617e5d1d5d3 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -7,6 +7,6 @@ .gcp-signup-offer--copy %h4= s_('ClusterIntegration|Did you know?') %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } - %a.btn.btn-default{ href: 'https://goo.gl/AaJzRW', target: '_blank', rel: 'noopener noreferrer' } + %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } = s_("ClusterIntegration|Apply for credit") diff --git a/app/views/clusters/clusters/aws/_new.html.haml b/app/views/clusters/clusters/aws/_new.html.haml new file mode 100644 index 00000000000..48467f88f52 --- /dev/null +++ b/app/views/clusters/clusters/aws/_new.html.haml @@ -0,0 +1,20 @@ +- if !Gitlab::CurrentSettings.eks_integration_enabled? + - documentation_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path("integration/amazon") } + = s_('Amazon authentication is not %{link_start}correctly configured%{link_end}. Ask your GitLab administrator if you want to use this service.').html_safe % { link_start: documentation_link_start, link_end: '<a/>'.html_safe } +- else + .js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), + 'create-role-path' => clusterable.authorize_aws_role_path, + 'sign-out-path' => clusterable.revoke_aws_role_path, + 'create-cluster-path' => clusterable.create_aws_clusters_path, + 'get-roles-path' => clusterable.aws_api_proxy_path('roles'), + 'get-regions-path' => clusterable.aws_api_proxy_path('regions'), + 'get-key-pairs-path' => clusterable.aws_api_proxy_path('key_pairs'), + 'get-vpcs-path' => clusterable.aws_api_proxy_path('vpcs'), + 'get-subnets-path' => clusterable.aws_api_proxy_path('subnets'), + 'get-security-groups-path' => clusterable.aws_api_proxy_path('security_groups'), + 'get-instance-types-path' => clusterable.aws_api_proxy_path('instance_types'), + 'account-id' => Gitlab::CurrentSettings.eks_account_id, + 'external-id' => @aws_role.role_external_id, + 'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index'), + 'external-link-icon' => icon('external-link'), + 'has-credentials' => @aws_role.role_arn.present?.to_s } } diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml index d4999798c19..56d46580b9e 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_button.html.haml @@ -1,8 +1,10 @@ - provider = local_assigns.fetch(:provider) - logo_path = local_assigns.fetch(:logo_path) - label = local_assigns.fetch(:label) +- last = local_assigns.fetch(:last, false) +- classes = ['btn btn-light btn-outline flex-fill d-inline-flex flex-column justify-content-center align-items-center', ('mr-3' unless last)] -= link_to clusterable.new_path(provider: provider), class: 'btn gl-button btn-outline flex-fill d-inline-flex flex-column mr-3 justify-content-center align-items-center' do - .svg-content= image_tag logo_path, alt: label, class: 'gl-w-13 gl-h-13' += link_to clusterable.new_path(provider: provider), class: classes do + .svg-content.p-2= image_tag logo_path, alt: label, class: 'gl-w-64 gl-h-64' %span = label diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml index 7a93a7604f5..91925f5f96f 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml @@ -2,10 +2,10 @@ - eks_label = s_('ClusterIntegration|Amazon EKS') - create_cluster_label = s_('ClusterIntegration|Create cluster on') .d-flex.flex-column - %h5 + %h5.mb-3 = create_cluster_label .d-flex = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', - locals: { provider: 'eks', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' } + locals: { provider: 'aws', label: eks_label, logo_path: 'illustrations/logos/amazon_eks.svg' } = render partial: 'clusters/clusters/cloud_providers/cloud_provider_button', - locals: { provider: 'gke', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg' } + locals: { provider: 'gcp', label: gke_label, logo_path: 'illustrations/logos/google_gke.svg', last: true } diff --git a/app/views/clusters/clusters/eks/_index.html.haml b/app/views/clusters/clusters/eks/_index.html.haml deleted file mode 100644 index db64698a7f2..00000000000 --- a/app/views/clusters/clusters/eks/_index.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.js-create-eks-cluster-form-container{ data: { 'gitlab-managed-cluster-help-path' => help_page_path('user/project/clusters/index.md', anchor: 'gitlab-managed-clusters'), -'kubernetes-integration-help-path' => help_page_path('user/project/clusters/index') } } diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index cca16ce7eda..95670a2ec87 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -64,12 +64,13 @@ %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } - .form-group - = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'), - label_class: 'label-bold' } - .form-text.text-muted - = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), target: '_blank' + - if Feature.enabled?(:create_cloud_run_clusters, clusterable) + .form-group + = provider_gcp_field.check_box :cloud_run, { label: s_('ClusterIntegration|Enable Cloud Run on GKE (beta)'), + label_class: 'label-bold' } + .form-text.text-muted + = s_('ClusterIntegration|Uses the Cloud Run, Istio, and HTTP Load Balancing addons for this cluster.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'cloud-run-on-gke'), target: '_blank' .form-group = field.check_box :managed, { label: s_('ClusterIntegration|GitLab-managed cluster'), diff --git a/app/views/clusters/clusters/gcp/_new.html.haml b/app/views/clusters/clusters/gcp/_new.html.haml new file mode 100644 index 00000000000..3d47f4bf2c3 --- /dev/null +++ b/app/views/clusters/clusters/gcp/_new.html.haml @@ -0,0 +1,7 @@ += render 'clusters/clusters/gcp/header' +- if @valid_gcp_token + = render 'clusters/clusters/gcp/form' +- elsif @authorize_url + = render 'clusters/clusters/gcp/signin_with_google_button' +- else + = render 'clusters/clusters/gcp/gcp_not_configured' diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 9bab3bf56aa..049010cadf4 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -16,7 +16,7 @@ .bs-callout.bs-callout-info = s_('ClusterIntegration|Clusters are utilized by selecting the nearest ancestor with a matching environment scope. For example, project clusters will override group clusters.') %strong - = link_to _('More information'), help_page_path('user/group/clusters/', anchor: 'cluster-precedence') + = link_to _('More information'), help_page_path('user/group/clusters/index', anchor: 'cluster-precedence') .clusters-table.js-clusters-list .gl-responsive-table-row.table-row-header{ role: "row" } diff --git a/app/views/clusters/clusters/new.html.haml b/app/views/clusters/clusters/new.html.haml index 2c23426aaf9..cb8cbe4e6f2 100644 --- a/app/views/clusters/clusters/new.html.haml +++ b/app/views/clusters/clusters/new.html.haml @@ -2,9 +2,6 @@ - page_title _('Kubernetes Cluster') - create_eks_enabled = Feature.enabled?(:create_eks_clusters) - active_tab = local_assigns.fetch(:active_tab, 'create') -- create_on_gke_tab_label = s_('ClusterIntegration|Create new Cluster on GKE') -- create_on_eks_tab_label = s_('ClusterIntegration|Create new Cluster on EKS') -- create_new_cluster_label = s_('ClusterIntegration|Create new Cluster') = javascript_include_tag 'https://apis.google.com/js/api.js' = render_gcp_signup_offer @@ -18,14 +15,9 @@ %a.nav-link{ href: '#create-cluster-pane', id: 'create-cluster-tab', class: active_when(active_tab == 'create'), data: { toggle: 'tab' }, role: 'tab' } %span - if create_eks_enabled - - if @gke_selected - = create_on_gke_tab_label - - elsif @eks_selected - = create_on_eks_tab_label - - else - = create_new_cluster_label + = create_new_cluster_label(provider: params[:provider]) - else - = create_on_gke_tab_label + = create_new_cluster_label(provider: 'gcp') %li.nav-item{ role: 'presentation' } %a.nav-link{ href: '#add-cluster-pane', id: 'add-cluster-tab', class: active_when(active_tab == 'add'), data: { toggle: 'tab' }, role: 'tab' } %span Add existing cluster @@ -33,27 +25,10 @@ .tab-content.gitlab-tab-content - if create_eks_enabled .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - - if @gke_selected - = render 'clusters/clusters/gcp/header' - - if @valid_gcp_token - = render 'clusters/clusters/gcp/form' - - elsif @authorize_url - = render 'clusters/clusters/gcp/signin_with_google_button' - - else - = render 'clusters/clusters/gcp/gcp_not_configured' - - elsif @eks_selected - = render 'clusters/clusters/eks/index' - - else - = render 'clusters/clusters/cloud_providers/cloud_provider_selector' + = render new_cluster_partial(provider: params[:provider]) - else .tab-pane{ id: 'create-cluster-pane', class: active_when(active_tab == 'create'), role: 'tabpanel' } - = render 'clusters/clusters/gcp/header' - - if @valid_gcp_token - = render 'clusters/clusters/gcp/form' - - elsif @authorize_url - = render 'clusters/clusters/gcp/signin_with_google_button' - - else - = render 'clusters/clusters/gcp/gcp_not_configured' + = render new_cluster_partial(provider: 'gcp') .tab-pane{ id: 'add-cluster-pane', class: active_when(active_tab == 'add'), role: 'tabpanel' } = render 'clusters/clusters/user/header' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 31d5f592d75..5beeaf7259a 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -12,11 +12,13 @@ install_helm_path: clusterable.install_applications_cluster_path(@cluster, :helm), install_ingress_path: clusterable.install_applications_cluster_path(@cluster, :ingress), install_cert_manager_path: clusterable.install_applications_cluster_path(@cluster, :cert_manager), + install_crossplane_path: clusterable.install_applications_cluster_path(@cluster, :crossplane), install_prometheus_path: clusterable.install_applications_cluster_path(@cluster, :prometheus), install_runner_path: clusterable.install_applications_cluster_path(@cluster, :runner), install_jupyter_path: clusterable.install_applications_cluster_path(@cluster, :jupyter), 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), 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/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index a6acf948ed4..39b6d74d9f9 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -1,7 +1,7 @@ -- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', - anchor: 'add-existing-kubernetes-cluster'), target: '_blank' -- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/index.md', - anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' +- more_info_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', + anchor: 'add-existing-cluster'), target: '_blank' +- rbac_help_link = link_to _('More information'), help_page_path('user/project/clusters/add_remove_clusters.md', + anchor: 'access-controls'), target: '_blank' - api_url_help_text = s_('ClusterIntegration|The URL used to access the Kubernetes API.') - ca_cert_help_text = s_('ClusterIntegration|The Kubernetes certificate used to authenticate to the cluster.') diff --git a/app/views/clusters/clusters/user/_header.html.haml b/app/views/clusters/clusters/user/_header.html.haml index 3b9ceaa2b8a..b0a24ee464f 100644 --- a/app/views/clusters/clusters/user/_header.html.haml +++ b/app/views/clusters/clusters/user/_header.html.haml @@ -1,5 +1,5 @@ %h4 = s_('ClusterIntegration|Enter the details for your Kubernetes cluster') %p - - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/index', anchor: 'add-existing-kubernetes-cluster'), target: '_blank', rel: 'noopener noreferrer') + - link_to_help_page = link_to(s_('ClusterIntegration|documentation'), help_page_path('user/project/clusters/add_remove_clusters', anchor: 'add-existing-cluster'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Please enter access information for your Kubernetes cluster. If you need help, you can read our %{link_to_help_page} on Kubernetes').html_safe % { link_to_help_page: link_to_help_page } diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index a2b1f0d9298..b5f5025b581 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -3,7 +3,7 @@ .container.section-body .row .blank-state-welcome.w-100 - %h2.blank-state-welcome-title + %h2.blank-state-welcome-title{ data: { qa_selector: 'welcome_title_content' } } = _('Welcome to GitLab') %p.blank-state-text = _('Faster releases. Better code. Less pain.') diff --git a/app/views/devise/sessions/new.html.haml b/app/views/devise/sessions/new.html.haml index 8f6c3ecbe58..fd6d8f3f769 100644 --- a/app/views/devise/sessions/new.html.haml +++ b/app/views/devise/sessions/new.html.haml @@ -1,13 +1,13 @@ - page_title "Sign in" #signin-container - - if form_based_providers.any? + - if any_form_based_providers_enabled? = render 'devise/shared/tabs_ldap' - else - unless experiment_enabled?(:signup_flow) = render 'devise/shared/tabs_normal' .tab-content - - if password_authentication_enabled_for_web? || ldap_enabled? || crowd_enabled? + - if password_authentication_enabled_for_web? || ldap_sign_in_enabled? || crowd_enabled? = render 'devise/shared/signin_box' -# Signup only makes sense if you can also sign-in @@ -15,7 +15,7 @@ = render 'devise/shared/signup_box' -# Show a message if none of the mechanisms above are enabled - - if !password_authentication_enabled_for_web? && !ldap_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?) + - if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?) %div No authentication methods configured. diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml index 746d43edbad..6ddb7e1ac48 100644 --- a/app/views/devise/shared/_signin_box.html.haml +++ b/app/views/devise/shared/_signin_box.html.haml @@ -1,4 +1,4 @@ -- if form_based_providers.any? +- if any_form_based_providers_enabled? - if crowd_enabled? .login-box.tab-pane{ id: "crowd", role: 'tabpanel', class: active_when(form_based_auth_provider_has_active_class?(:crowd)) } .login-body diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index db54c166a53..b8f0cd2a91a 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -1,4 +1,4 @@ -%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if form_based_providers.any?) } +%ul.nav-links.new-session-tabs.nav-tabs.nav{ class: ('custom-provider-tabs' if any_form_based_providers_enabled?) } - if crowd_enabled? %li.nav-item = link_to "Crowd", "#crowd", class: "nav-link #{active_when(form_based_auth_provider_has_active_class?(:crowd))}", 'data-toggle' => 'tab' diff --git a/app/views/errors/not_found.html.haml b/app/views/errors/not_found.html.haml index ae055f398ac..13f07e2f5d5 100644 --- a/app/views/errors/not_found.html.haml +++ b/app/views/errors/not_found.html.haml @@ -11,5 +11,5 @@ = form_tag search_path, method: :get, class: 'form-inline-flex' do |f| .field = search_field_tag :search, '', placeholder: _('Search for projects, issues, etc.'), class: 'form-control' - = button_tag 'Search', class: 'btn btn-success', name: nil, type: 'submit' + = button_tag _('Search'), class: 'btn btn-sm btn-success', name: nil, type: 'submit' = render 'errors/footer' diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index bf077eb09d2..1cb1cc45bdb 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -22,4 +22,10 @@ - if @can_bulk_update = render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues - = render 'shared/issues' + - if Feature.enabled?(:vue_issuables_list, @group) + .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)), + 'can-bulk-edit': @can_bulk_update.to_json, + 'empty-svg-path': image_path('illustrations/issues.svg'), + 'sort-key': @sort } } + - else + = render 'shared/issues' diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index 23b1a22240f..33e68bc766e 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -1,4 +1,4 @@ = render "header_title" = render 'shared/milestones/top', milestone: @milestone, group: @group -= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.legacy_group_milestone? += render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true = render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102 diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index a3f35b72cc6..81bd15ed287 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -31,7 +31,8 @@ %button.btn.btn-default.js-settings-toggle{ type: "button" } = expanded ? _('Collapse') : _('Expand') %p - = _('Register and see your runners for this group.') + = _("Runners are processes that pick up and execute jobs for GitLab. Here you can register and see your Runners for this project.") + = link_to s_('More information'), help_page_path('ci/runners/README') .settings-content = render 'groups/runners/index' diff --git a/app/views/help/_shortcuts.html.haml b/app/views/help/_shortcuts.html.haml index f1ba804f920..5f8f2333e40 100644 --- a/app/views/help/_shortcuts.html.haml +++ b/app/views/help/_shortcuts.html.haml @@ -218,7 +218,7 @@ %tr %td.shortcut %kbd esc - %td= _('Go back (while searching for files') + %td= _('Go back (while searching for files)') %tr %td.shortcut %kbd y diff --git a/app/views/import/manifest/_form.html.haml b/app/views/import/manifest/_form.html.haml index 78c7fadb019..b515ce084e4 100644 --- a/app/views/import/manifest/_form.html.haml +++ b/app/views/import/manifest/_form.html.haml @@ -13,7 +13,7 @@ .form-group = label_tag :manifest, class: 'label-bold' do = _('Manifest') - = file_field_tag :manifest, class: 'form-control-file', required: true + = file_field_tag :manifest, class: 'form-control-file w-auto', required: true .form-text.text-muted = _('Import multiple repositories by uploading a manifest file.') = link_to icon('question-circle'), help_page_path('user/project/import/manifest') diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 92572f0308c..a0b030fa3b2 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -3,7 +3,7 @@ - flash.each do |key, value| -# Don't show a flash message if the message is nil - if value - %div{ class: "flash-content flash-#{key} rounded" } + %div{ class: "flash-#{key} mb-2" } %span= value %div{ class: "close-icon-wrapper js-close-icon" } = sprite_icon('close', size: 16, css_class: 'close-icon') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index b8c9f0ae1e8..0060d8323b0 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -57,7 +57,7 @@ = yield :library_javascripts = javascript_include_tag locale_path unless I18n.locale == :en - = webpack_bundle_tag "raven" if Gitlab.config.sentry.enabled + = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled - if content_for?(:page_specific_javascripts) = yield :page_specific_javascripts @@ -89,4 +89,4 @@ = render 'layouts/google_analytics' if extra_config.has_key?('google_analytics_id') = render 'layouts/piwik' if extra_config.has_key?('piwik_url') && extra_config.has_key?('piwik_site_id') - = render_if_exists 'layouts/snowplow' + = render 'layouts/snowplow' diff --git a/app/views/layouts/_mailer.html.haml b/app/views/layouts/_mailer.html.haml index 6e8294d6adc..24b8138078d 100644 --- a/app/views/layouts/_mailer.html.haml +++ b/app/views/layouts/_mailer.html.haml @@ -1,80 +1,48 @@ -<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional //EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> +<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> %html{ lang: "en" } %head %meta{ content: "text/html; charset=UTF-8", "http-equiv" => "Content-Type" }/ %meta{ content: "width=device-width, initial-scale=1", name: "viewport" }/ %meta{ content: "IE=edge", "http-equiv" => "X-UA-Compatible" }/ %title= message.subject - :css - /* CLIENT-SPECIFIC STYLES */ - body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; } - table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; } - img { -ms-interpolation-mode: bicubic; } - .hidden { - display: none !important; - visibility: hidden !important; - } - /* iOS BLUE LINKS */ - a[x-apple-data-detectors] { - color: inherit !important; - text-decoration: none !important; - font-size: inherit !important; - font-family: inherit !important; - font-weight: inherit !important; - line-height: inherit !important; - } + -# Avoid premailer processing of client-specific styles (@media tag not supported) + -# We need to inline the contents here because mail clients (e.g. iOS Mail, Outlook) + -# do not support linked stylesheets. + %style{ type: 'text/css', 'data-premailer': 'ignore' } + = asset_to_string('mailer_client_specific.css').html_safe - /* ANDROID MARGIN HACK */ - body { margin:0 !important; } - div[style*="margin: 16px 0"] { margin:0 !important; } - - @media only screen and (max-width: 639px) { - body, #body { - min-width: 320px !important; - } - table.wrapper { - width: 100% !important; - min-width: 320px !important; - } - table.wrapper > tbody > tr > td { - border-left: 0 !important; - border-right: 0 !important; - border-radius: 0 !important; - padding-left: 10px !important; - padding-right: 10px !important; - } - } - %body{ style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;height:100%;font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } - %table#body{ border: "0", cellpadding: "0", cellspacing: "0", style: "background-color:#fafafa;margin:0;padding:0;text-align:center;min-width:640px;width:100%;" } + = stylesheet_link_tag 'mailer.css' + %body + %table#body{ border: "0", cellpadding: "0", cellspacing: "0" } %tbody %tr.line - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#6b4fbb;height:4px;font-size:4px;line-height:4px;" } + %td %tr.header - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %td = html_header_message = header_logo %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;" } - %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:640px;margin:0 auto;border-collapse:separate;border-spacing:0;" } + %td + %table.wrapper{ border: "0", cellpadding: "0", cellspacing: "0" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;background-color:#ffffff;text-align:left;padding:18px 25px;border:1px solid #ededed;border-radius:3px;overflow:hidden;" } - %table.content{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;border-collapse:separate;border-spacing:0;" } + %td.wrapper-cell + %table.content{ border: "0", cellpadding: "0", cellspacing: "0" } %tbody = yield = render_if_exists 'layouts/mailer/additional_text' %tr.footer - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } - %img{ alt: "GitLab", height: "33", src: image_url('mailers/gitlab_footer_logo.gif'), style: "display:block;margin:0 auto 1em;", width: "90" }/ + %td + %img{ alt: "GitLab", height: "33", width: "90", src: image_url('mailers/gitlab_footer_logo.gif') } %div - - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, style: "color:#3777b0;text-decoration:none;") - - help_link = link_to(_("Help"), help_url, style: "color:#3777b0;text-decoration:none;") + - manage_notifications_link = link_to(_("Manage all notifications"), profile_notifications_url, class: 'mng-notif-link') + - help_link = link_to(_("Help"), help_url, class: 'help-link') = _("You're receiving this email because of your account on %{host}. %{manage_notifications_link} · %{help_link}").html_safe % { host: Gitlab.config.gitlab.host, manage_notifications_link: manage_notifications_link, help_link: help_link } = yield :additional_footer %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:25px 0;font-size:13px;line-height:1.6;color:#5c5c5c;" } + %td.footer-message = html_footer_message diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 6cdb85456c3..443a73f5cce 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -17,6 +17,4 @@ %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content{ id: "content-body" } = render "layouts/flash", extra_flash_class: 'limit-container-width' - - if Gitlab.com? - = render_if_exists "layouts/privacy_policy_update_callout" = yield diff --git a/app/views/layouts/header/_current_user_dropdown.html.haml b/app/views/layouts/header/_current_user_dropdown.html.haml index efe74ddd902..d15f0ae3228 100644 --- a/app/views/layouts/header/_current_user_dropdown.html.haml +++ b/app/views/layouts/header/_current_user_dropdown.html.haml @@ -18,6 +18,11 @@ - if current_user_menu?(:profile) %li = link_to s_("CurrentUser|Profile"), current_user, class: 'profile-link', data: { user: current_user.username } + - if current_user_menu?(:start_trial) + %li + %a.profile-link{ href: trials_link_url } + = s_("CurrentUser|Start a Gold trial") + = emoji_icon('rocket') - if current_user_menu?(:settings) %li = link_to s_("CurrentUser|Settings"), profile_path, data: { qa_selector: 'settings_link' } @@ -35,8 +40,8 @@ %li.d-md-none = render 'shared/user_dropdown_contributing_link' = render_if_exists 'shared/user_dropdown_instance_review' - - if Gitlab.com? - %li.js-canary-link.d-md-none + - if Gitlab.com_but_not_canary? + %li.d-md-none = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" - if current_user_menu?(:sign_out) diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index d8697be7f7a..5719fb24b89 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -18,8 +18,8 @@ - if logo_text.present? %span.logo-text.d-none.d-lg-block.prepend-left-8 = logo_text - - if Gitlab.com? - = link_to 'https://next.gitlab.com', class: 'label-link js-canary-badge canary-badge bg-transparent hidden', target: :_blank do + - if Gitlab.com_and_canary? + = link_to 'https://next.gitlab.com', class: 'label-link canary-badge bg-transparent', target: :_blank do %span.color-label.has-tooltip.badge.badge-pill.green-badge = _('Next') diff --git a/app/views/layouts/header/_help_dropdown.html.haml b/app/views/layouts/header/_help_dropdown.html.haml index 71977b23481..93854c212df 100644 --- a/app/views/layouts/header/_help_dropdown.html.haml +++ b/app/views/layouts/header/_help_dropdown.html.haml @@ -12,6 +12,6 @@ %li = render 'shared/user_dropdown_contributing_link' = render_if_exists 'shared/user_dropdown_instance_review' - - if Gitlab.com? - %li.js-canary-link + - if Gitlab.com_but_not_canary? + %li = link_to _("Switch to GitLab Next"), "https://next.gitlab.com/" diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 5122c2517aa..d339751848b 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -55,15 +55,15 @@ = nav_link(controller: 'admin/dashboard') do = link_to admin_root_path, class: 'admin-icon qa-admin-area-link d-xl-none' do = _('Admin Area') - - if Feature.enabled?(:user_mode_in_session) - - if header_link?(:admin_mode) - = nav_link(controller: 'admin/sessions') do - = link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do - = _('Leave admin mode') - - elsif current_user.admin? - = nav_link(controller: 'admin/sessions') do - = link_to new_admin_session_path, class: 'd-lg-none lock-icon' do - = _('Enter admin mode') + - if Feature.enabled?(:user_mode_in_session) + - if header_link?(:admin_mode) + = nav_link(controller: 'admin/sessions') do + = link_to destroy_admin_session_path, class: 'd-lg-none lock-open-icon' do + = _('Leave Admin Mode') + - elsif current_user.admin? + = nav_link(controller: 'admin/sessions') do + = link_to new_admin_session_path, class: 'd-lg-none lock-icon' do + = _('Enter Admin Mode') - if Gitlab::Sherlock.enabled? %li = link_to sherlock_transactions_path, class: 'admin-icon' do @@ -74,6 +74,15 @@ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = sprite_icon('admin', size: 18) + - if Feature.enabled?(:user_mode_in_session) + - if header_link?(:admin_mode) + = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do + = link_to destroy_admin_session_path, title: _('Leave Admin Mode'), aria: { label: _('Leave Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = sprite_icon('lock-open', size: 18) + - elsif current_user.admin? + = nav_link(controller: 'admin/sessions', html_options: { class: "d-none d-lg-block d-xl-block"}) do + = link_to new_admin_session_path, title: _('Enter Admin Mode'), aria: { label: _('Enter Admin Mode') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do + = sprite_icon('lock', size: 18) -# Shortcut to Dashboard > Projects - if dashboard_nav_link?(:projects) diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 4930c6cf5f7..a6d2c894185 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -16,13 +16,19 @@ .nav-icon-container = sprite_icon('home') %span.nav-item-name - = _('Overview') + - if @group.subgroup? + = _('Subgroup overview') + - else + = _('Group overview') %ul.sidebar-sub-level-items = nav_link(path: ['groups#show', 'groups#details', 'groups#activity', 'groups#subgroups'], html_options: { class: "fly-out-top-item" } ) do = link_to group_path(@group) do %strong.fly-out-top-item-name - = _('Overview') + - if @group.subgroup? + = _('Subgroup overview') + - else + = _('Group overview') %li.divider.fly-out-top-item = nav_link(path: ['groups#show', 'groups#details', 'groups#subgroups'], html_options: { class: 'home' }) do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index c84bc0b5cd4..9b3ad05d0c0 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -13,13 +13,13 @@ .nav-icon-container = sprite_icon('home') %span.nav-item-name - = _('Project') + = _('Project overview') %ul.sidebar-sub-level-items = nav_link(path: 'projects#show', html_options: { class: "fly-out-top-item" } ) do = link_to project_path(@project) do %strong.fly-out-top-item-name - = _('Project') + = _('Project overview') %li.divider.fly-out-top-item = nav_link(path: 'projects#show') do = link_to project_path(@project), title: _('Project details'), class: 'shortcuts-project' do @@ -163,7 +163,7 @@ - if project_nav_tab? :pipelines = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts]) do - = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines' do + = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines', data: { qa_selector: 'ci_cd_link' } do .nav-icon-container = sprite_icon('rocket') %span.nav-item-name#js-onboarding-pipelines-link @@ -247,6 +247,8 @@ %span = _('Serverless') + = render_if_exists 'layouts/nav/sidebar/pod_logs_link' # EE-specific + - if project_nav_tab? :clusters - show_cluster_hint = show_gke_cluster_integration_callout?(@project) = nav_link(controller: [:clusters, :user, :gcp]) do @@ -264,7 +266,7 @@ dismiss_endpoint: user_callouts_path } } - if show_cluster_hint .feature-highlight-popover-content - = image_tag 'illustrations/cluster_popover.svg', class: 'feature-highlight-illustration' + = image_tag 'illustrations/cluster_popover.svg', class: 'feature-highlight-illustration', lazy: false, alt: _('Kubernetes popover') .feature-highlight-popover-sub-content %p= _('Allows you to add and manage Kubernetes clusters.') %p @@ -347,7 +349,7 @@ = _('Members') - if can_edit = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do - = link_to project_settings_integrations_path(@project), title: _('Integrations') do + = link_to project_settings_integrations_path(@project), title: _('Integrations'), data: { qa_selector: 'integrations_settings_link' } do %span = _('Integrations') = nav_link(controller: :repository) do diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml index 71c9c50071a..11661a423dd 100644 --- a/app/views/notify/member_access_denied_email.html.haml +++ b/app/views/notify/member_access_denied_email.html.haml @@ -1,4 +1,7 @@ -%p - Your request to join the - #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular} - has been denied. +%tr + %td.text-content + %p + Your request to join the + #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular} + has been #{content_tag :span, 'denied', class: :highlight}. + diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml index 1c50dba9c97..e28a10a243f 100644 --- a/app/views/notify/member_access_granted_email.html.haml +++ b/app/views/notify/member_access_granted_email.html.haml @@ -1,10 +1,14 @@ - link_end = '</a>'.html_safe - source_type = member_source.model_name.singular - leave_link = polymorphic_url([member_source], leave: 1) -- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer') +- source_link = link_to(member_source.human_name, member_source.web_url, target: '_blank', rel: 'noopener noreferrer', class: :highlight) +- access_level = content_tag(:span, member.human_access, class: :highlight) + +%tr + %td.text-content + %p + = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: access_level, source_link: source_link, source_type: source_type } + %p + - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } + = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } -%p - = _('You have been granted %{access_level} access to the %{source_link} %{source_type}.').html_safe % { access_level: member.human_access, source_link: source_link, source_type: source_type } -%p - - leave_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: leave_link } - = _('If this was a mistake you can %{leave_link_start}leave the %{source_type}%{link_end}.').html_safe % { source_type: source_type, leave_link_start: leave_link_start, link_end: link_end } diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml index 76f1f08a0cb..43f25af3dba 100644 --- a/app/views/notify/member_access_requested_email.html.haml +++ b/app/views/notify/member_access_requested_email.html.haml @@ -1,3 +1,6 @@ -%p - #{link_to member.user.name, member.user} requested #{member.human_access} - access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members])} #{member_source.model_name.singular}. +%tr + %td.text-content + %p + #{link_to member.user.name, member.user, class: :highlight} requested #{content_tag :span, member.human_access, class: :highlight} + access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members]), class: :highlight} #{member_source.model_name.singular}. + diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml index 2d1d40881eb..0abb79000e0 100644 --- a/app/views/notify/member_invite_accepted_email.html.haml +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -1,5 +1,8 @@ -%p - #{member.invite_email}, now known as - #{link_to member.user.name, user_url(member.user)}, - has accepted your invitation to join the - #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. +%tr + %td.text-content + %p + #{content_tag :span, member.invite_email, class: :highlight}, now known as + #{link_to member.user.name, user_url(member.user)}, + has accepted your invitation to join the + #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}. + diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml index aa1b373d1a6..5e626767235 100644 --- a/app/views/notify/member_invite_declined_email.html.haml +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -1,4 +1,7 @@ -%p - #{@invite_email} - has declined your invitation to join the - #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. +%tr + %td.text-content + %p + #{content_tag :span, @invite_email, class: :highlight} + has #{content_tag :span, 'declined', class: :highlight} your invitation to join the + #{link_to member_source.human_name, member_source.web_url, class: :highlight} #{member_source.model_name.singular}. + diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index 6730172242b..ae3fecf404a 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -1,13 +1,16 @@ -%p - You have been invited - - if member.created_by - by - = link_to member.created_by.name, user_url(member.created_by) - to join the - = link_to member_source.human_name, member_source.public? ? member_source.web_url : invite_url(@token) - #{member_source.model_name.singular} as #{member.human_access}. +%tr + %td.text-content + %p + You have been invited + - if member.created_by + by + = link_to member.created_by.name, user_url(member.created_by) + to join the + = link_to member_source.human_name, member_source.public? ? member_source.web_url : invite_url(@token), class: :highlight + #{member_source.model_name.singular} as #{content_tag :span, member.human_access, class: :highlight}. + + %p + = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/profiles/preferences/_sourcegraph.html.haml b/app/views/profiles/preferences/_sourcegraph.html.haml new file mode 100644 index 00000000000..20a904694ca --- /dev/null +++ b/app/views/profiles/preferences/_sourcegraph.html.haml @@ -0,0 +1,26 @@ +- return unless Gitlab::Sourcegraph::feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled +- sourcegraph_url = Gitlab::CurrentSettings.sourcegraph_url + +.col-sm-12 + %hr + +.col-lg-4.profile-settings-sidebar + %h4.prepend-top-0 + = s_('Preferences|Integrations') + %p + = s_('Preferences|Customize integrations with third party services.') + = succeed '.' do + = link_to _('Learn more'), help_page_path('user/profile/preferences.md', anchor: 'integrations'), target: '_blank' +.col-lg-8 + %label.label-bold + = s_('Preferences|Sourcegraph') + = link_to icon('question-circle'), help_page_path('user/profile/preferences.md', anchor: 'sourcegraph'), target: '_blank', class: 'has-tooltip', title: _('More information') + .form-group.form-check + = f.check_box :sourcegraph_enabled, class: 'form-check-input' + = f.label :sourcegraph_enabled, class: 'form-check-label' do + - link_start = '<a href="%{url}">'.html_safe % { url: sourcegraph_url } + - link_end = '</a>'.html_safe + = s_('Preferences|Enable integrated code intelligence on code views').html_safe % { link_start: link_start, link_end: link_end } + .form-text.text-muted + = sourcegraph_url_message + = sourcegraph_experimental_message diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index 84657592cd8..bf76b7379dd 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -111,6 +111,9 @@ = time_display_label .form-text.text-muted = s_('Preferences|For example: 30 mins ago.') + + = render 'sourcegraph', f: f + .col-lg-4.profile-settings-sidebar .col-lg-8 .form-group diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 68b7efc6fb4..cfad274f91d 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -94,7 +94,7 @@ - else = f.text_field :name, label: s_('Profiles|Full name'), required: true, title: s_("Profiles|Using emojis in names seems fun, but please try to set a status message instead"), wrapper: { class: 'col-md-9 qa-full-name rspec-full-name' }, help: s_("Profiles|Enter your name, so people you know can recognize you") = f.text_field :id, readonly: true, label: s_('Profiles|User ID'), wrapper: { class: 'col-md-3' } - = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'input-md' + = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, { prompt: _('Select your role') }, required: true, class: 'input-md' = render_if_exists 'profiles/email_settings', form: f = f.text_field :skype, class: 'input-md', placeholder: s_("Profiles|username") diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index e4129a91daf..2e00632892b 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -14,7 +14,7 @@ %li= desc %p= _('The following items will NOT be exported:') %ul - %li= _('Job traces and artifacts') + %li= _('Job logs and artifacts') %li= _('Container registry images') %li= _('CI variables') %li= _('Webhooks') diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 95fdad125a7..20d4084f428 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -15,15 +15,13 @@ = render 'shared/commit_well', commit: commit, ref: ref, project: project - if is_project_overview - .project-buttons.append-bottom-default{ class: ("js-keep-hidden-on-navigation" if vue_file_list_enabled?) } + .project-buttons.append-bottom-default{ class: ("js-show-on-project-root" if vue_file_list_enabled?) } = render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout) - if vue_file_list_enabled? - #js-tree-list{ data: { project_path: @project.full_path, project_short_path: @project.path, ref: ref, full_name: @project.name_with_namespace } } + #js-tree-list{ data: vue_file_list_data(project, ref) } - if can_edit_tree? = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post = render 'projects/blob/new_dir' - - if @tree.readme - = render "projects/tree/readme", readme: @tree.readme - else = render 'projects/tree/tree_content', tree: @tree, content_url: content_url diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 4783b10cf6d..b7c4114d485 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -3,7 +3,7 @@ - max_project_topic_length = 15 - emails_disabled = @project.emails_disabled? -.project-home-panel{ class: [("empty-project" if empty_repo), ("js-keep-hidden-on-navigation" if vue_file_list_enabled?)] } +.project-home-panel{ class: [("empty-project" if empty_repo), ("js-show-on-project-root" if vue_file_list_enabled?)] } .row.append-bottom-8 .home-panel-title-row.col-md-12.col-lg-6.d-flex .avatar-container.rect-avatar.s64.home-panel-avatar.append-right-default.float-none diff --git a/app/views/projects/_merge_request_merge_options_settings.html.haml b/app/views/projects/_merge_request_merge_options_settings.html.haml index 5ab475822de..047b4dafbfc 100644 --- a/app/views/projects/_merge_request_merge_options_settings.html.haml +++ b/app/views/projects/_merge_request_merge_options_settings.html.haml @@ -12,3 +12,9 @@ = form.check_box :printing_merge_request_link_enabled, class: 'form-check-input' = form.label :printing_merge_request_link_enabled, class: 'form-check-label' do = s_('ProjectSettings|Show link to create/view merge request when pushing from the command line') + .form-check.mb-2 + = form.check_box :remove_source_branch_after_merge, class: 'form-check-input' + = form.label :remove_source_branch_after_merge, class: 'form-check-label' do + = s_("ProjectSettings|Enable 'Delete source branch' option by default") + .descr.text-secondary + = s_('ProjectSettings|Existing merge requests and protected branches are not affected') diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index f2215765974..30fe5622ebd 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -19,7 +19,7 @@ = author_avatar(commit, size: 36, has_tooltip: false) .commit-row-title %span.item-title.str-truncated-100 - = link_to_markdown commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title + = link_to commit.title, project_commit_path(@project, commit.id), class: "cdark", title: commit.title .float-right = link_to commit.short_id, project_commit_path(@project, commit), class: "commit-sha" diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index 84ccd816d80..77245114772 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -6,17 +6,18 @@ = render 'projects/blob/viewer_switcher', blob: blob unless blame .btn-group{ role: "group" }< - = copy_blob_source_button(blob) unless blame - = open_raw_blob_button(blob) - = download_blob_button(blob) - = view_on_environment_button(@commit.sha, @path, @environment) if @environment - .btn-group{ role: "group" }< - = render_if_exists 'projects/blob/header_file_locks_link' = edit_blob_button = ide_edit_button + .btn-group{ role: "group" }< + = render_if_exists 'projects/blob/header_file_locks_link' - if current_user = replace_blob_link = delete_blob_link + .btn-group{ role: "group" }< + = copy_blob_source_button(blob) unless blame + = open_raw_blob_button(blob) + = download_blob_button(blob) + = view_on_environment_button(@commit.sha, @path, @environment) if @environment = render 'projects/fork_suggestion' = render_if_exists 'projects/blob/header_file_locks', project: @project, path: @path diff --git a/app/views/projects/blob/_markdown_buttons.html.haml b/app/views/projects/blob/_markdown_buttons.html.haml index 28d1ff97825..44ec2fa69cb 100644 --- a/app/views/projects/blob/_markdown_buttons.html.haml +++ b/app/views/projects/blob/_markdown_buttons.html.haml @@ -6,7 +6,7 @@ = markdown_toolbar_button({ icon: "link", data: { "md-tag" => "[{text}](url)", "md-select" => "url" }, title: _("Add a link") }) = markdown_toolbar_button({ icon: "list-bulleted", data: { "md-tag" => "* ", "md-prepend" => true }, title: _("Add a bullet list") }) = markdown_toolbar_button({ icon: "list-numbered", data: { "md-tag" => "1. ", "md-prepend" => true }, title: _("Add a numbered list") }) - = markdown_toolbar_button({ icon: "task-done", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") }) + = markdown_toolbar_button({ icon: "list-task", data: { "md-tag" => "* [ ] ", "md-prepend" => true }, title: _("Add a task list") }) = markdown_toolbar_button({ icon: "table", data: { "md-tag" => "| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |", "md-prepend" => true }, title: _("Add a table") }) - if show_fullscreen_button %button.toolbar-btn.toolbar-fullscreen-btn.js-zen-enter.has-tooltip{ type: "button", tabindex: -1, "aria-label": "Go full screen", title: _("Go full screen"), data: { container: "body" } } diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index abef33ca01c..ed22573b23e 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -25,5 +25,3 @@ = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") = render_if_exists 'projects/buttons/geo' = render_if_exists 'projects/buttons/kerberos_clone_field' - -= render_if_exists 'shared/geo_info_modal', project: project diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 96df3cd18fe..e8aff58b505 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -12,11 +12,14 @@ %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 directory? && Feature.enabled?(:git_archive_path, default_enabled: true) - %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 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 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/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml index b256d94065b..990f3ff526b 100644 --- a/app/views/projects/buttons/_download_links.html.haml +++ b/app/views/projects/buttons/_download_links.html.haml @@ -1,6 +1,4 @@ -- formats = [['zip', 'btn-primary'], ['tar.gz'], ['tar.bz2'], ['tar']] - .btn-group.ml-0.w-100 - - formats.each do |(fmt, extra_class)| + - Gitlab::Workhorse::ARCHIVE_FORMATS.each_with_index do |fmt, index| - archive_path = project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: fmt) - = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "btn btn-xs #{extra_class}" + = link_to fmt, external_storage_url_or_path(archive_path), rel: 'nofollow', download: '', class: "btn btn-xs #{"btn-primary" if index == 0}" diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index 9744d293c8b..c8c96297672 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -8,8 +8,9 @@ .input-group-text = s_("CompareBranches|Source") = hidden_field_tag :to, params[:to] - = button_tag type: 'button', title: params[:to], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip monospace", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do - .dropdown-toggle-text.str-truncated= params[:to] || _("Select branch/tag") + = button_tag type: 'button', title: params[:to], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do + .dropdown-toggle-text.str-truncated.monospace.float-left= params[:to] || _("Select branch/tag") + = sprite_icon('arrow-down', size: 16, css_class: 'float-right') = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown @@ -18,8 +19,9 @@ .input-group-text = s_("CompareBranches|Target") = hidden_field_tag :from, params[:from] - = button_tag type: 'button', title: params[:from], class: "form-control compare-dropdown-toggle js-compare-dropdown has-tooltip monospace", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do - .dropdown-toggle-text.str-truncated= params[:from] || _("Select branch/tag") + = button_tag type: 'button', title: params[:from], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do + .dropdown-toggle-text.str-truncated.monospace.float-left= params[:from] || _("Select branch/tag") + = sprite_icon('arrow-down', size: 16, css_class: 'float-right') = render 'shared/ref_dropdown' = button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn" diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml index ff40e404e5f..9162827b501 100644 --- a/app/views/projects/deployments/_confirm_rollback_modal.html.haml +++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml @@ -13,7 +13,7 @@ %p= s_('Environments|This action will relaunch the job for commit %{commit_id}, putting the environment in a previous version. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} - else %p - = s_('Environments|This action will run the job defined by staging for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha} + = s_('Environments|This action will run the job defined by %{environment_name} for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha, environment_name: @environment.name} .modal-footer = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' } = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-danger' do diff --git a/app/views/projects/environments/empty_logs.html.haml b/app/views/projects/environments/empty_logs.html.haml new file mode 100644 index 00000000000..602dc908b75 --- /dev/null +++ b/app/views/projects/environments/empty_logs.html.haml @@ -0,0 +1,14 @@ +- page_title _('Pod logs') + +.row.empty-state + .col-sm-12 + .svg-content + = image_tag 'illustrations/operations_log_pods_empty.svg' + .col-12 + .text-content + %h4.text-center + = s_('Environments|No deployed environments') + %p.state-description.text-center + = s_('Logs|To see the pod logs, deploy your code to an environment.') + .text-center + = link_to s_('Environments|Learn about environments'), help_page_path('ci/environments'), class: 'btn btn-success' diff --git a/app/views/projects/environments/empty.html.haml b/app/views/projects/environments/empty_metrics.html.haml index 129dbbf4e56..dad93290fbd 100644 --- a/app/views/projects/environments/empty.html.haml +++ b/app/views/projects/environments/empty_metrics.html.haml @@ -7,8 +7,8 @@ .col-12 .text-content %h4.text-center - = s_('Metrics|No deployed environments') + = s_('Environments|No deployed environments') %p.state-description = s_('Metrics|Check out the CI/CD documentation on deploying to an environment') .text-center - = link_to s_("Metrics|Learn about environments"), help_page_path('ci/environments'), class: 'btn btn-success' + = link_to s_("Environments|Learn about environments"), help_page_path('ci/environments'), class: 'btn btn-success' diff --git a/app/views/projects/error_tracking/details.html.haml b/app/views/projects/error_tracking/details.html.haml new file mode 100644 index 00000000000..640746ad8f6 --- /dev/null +++ b/app/views/projects/error_tracking/details.html.haml @@ -0,0 +1,4 @@ +- page_title _('Error Details') +- add_to_breadcrumbs 'Errors', project_error_tracking_index_path(@project) + +#js-error_details{ data: error_details_data(@current_user, @project) } diff --git a/app/views/projects/graphs/show.html.haml b/app/views/projects/graphs/show.html.haml index 6e5e4607232..a952db0eea3 100644 --- a/app/views/projects/graphs/show.html.haml +++ b/app/views/projects/graphs/show.html.haml @@ -1,26 +1,8 @@ - page_title _('Contributors') -.js-graphs-show{ 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json) } - .sub-header-block - .tree-ref-holder.inline.vertical-align-middle - = render 'shared/ref_switcher', destination: 'graphs' - = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' +.sub-header-block.bg-gray-light.gl-p-3 + .tree-ref-holder.inline.vertical-align-middle + = render 'shared/ref_switcher', destination: 'graphs' + = link_to s_('Commits|History'), project_commits_path(@project, current_ref), class: 'btn' - .loading-graph - .center - %h3.page-title - %i.fa.fa-spinner.fa-spin - = s_('ContributorsPage|Building repository graph.') - %p.slead - = s_('ContributorsPage|Please wait a moment, this page will automatically refresh when ready.') - - .stat-graph.hide - .header.clearfix - %h3#date_header.page-title - %p.light - = s_('ContributorsPage|Commits to %{branch_name}, excluding merge commits. Limited to 6,000 commits.') % { branch_name: @ref } - %input#brush_change{ :type => "hidden" } - .graphs.row - #contributors-master.svg-w-100 - #contributors.clearfix - %ol.contributors-list.svg-w-100.row +.js-contributors-graph{ class: container_class, 'data-project-graph-path': project_graph_path(@project, current_ref, format: :json),'data-project-branch': current_ref } diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index 367b8c1138e..c8ab47888d0 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,4 +1,5 @@ -%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } } +-# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue! +%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue', qa_issue_title: issue.title } } .issue-box - if @can_bulk_update .issue-check.hidden diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 0328751c68c..0373e37818d 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -26,7 +26,7 @@ = render partial: 'shared/label', collection: @prioritized_labels, as: :label, locals: { force_priority: true, subject: @project } - elsif search.present? .nothing-here-block - = _('No prioritised labels with such name or description') + = _('No prioritized labels with such name or description') - if @labels.present? .other-labels diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml index 57205682bda..9cdbbe7204b 100644 --- a/app/views/projects/merge_requests/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/_how_to_merge.html.haml @@ -12,8 +12,8 @@ = clipboard_button(target: "pre#merge-info-1", title: _("Copy commands")) %pre.dark#merge-info-1 - if @merge_request.for_fork? + -# All repo/branch refs have been quoted to allow support for special characters (such as #my-branch) :preserve - -# All repo/branch refs have been quoted to allow support for special characters (such as #my-branch) git fetch "#{h default_url_to_repo(@merge_request.source_project)}" "#{h @merge_request.source_branch}" git checkout -b "#{h @merge_request.source_project_path}-#{h @merge_request.source_branch}" FETCH_HEAD - else diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index 543441b9479..15c83f92474 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -1,15 +1,5 @@ %h3.page-title New Merge Request -%p.slead - - source_title, target_title = format_mr_branch_names(@merge_request) - From - %strong.ref-name= source_title - %span into - %strong.ref-name= target_title - - %span.float-right - = link_to 'Change branches', mr_change_branches_path(@merge_request) -%hr = form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter = f.hidden_field :source_project_id diff --git a/app/views/projects/merge_requests/edit.html.haml b/app/views/projects/merge_requests/edit.html.haml index 03159f123f3..318c9d809c1 100644 --- a/app/views/projects/merge_requests/edit.html.haml +++ b/app/views/projects/merge_requests/edit.html.haml @@ -2,5 +2,4 @@ %h3.page-title Edit Merge Request #{@merge_request.to_reference} -%hr = render 'form' diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 49d3039d0c9..5f244d3a6c3 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -3,57 +3,8 @@ - page_title @milestone.title, _('Milestones') - page_description @milestone.description -.detail-page-header.milestone-page-header - .status-box{ class: status_box_class(@milestone) } - - if @milestone.closed? - = _('Closed') - - elsif @milestone.expired? - = _('Past due') - - elsif @milestone.upcoming? - = _('Upcoming') - - else - = _('Open') - .header-text-content - %span.identifier - %strong - = _('Milestone') - - if @milestone.due_date || @milestone.start_date - = milestone_date_range(@milestone) - .milestone-buttons - - if can?(current_user, :admin_milestone, @project) - = link_to edit_project_milestone_path(@project, @milestone), class: 'btn btn-grouped btn-nr' do - = _('Edit') - - - if @project.group - %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', - target: '#promote-milestone-modal', - milestone_title: @milestone.title, - group_name: @project.group.name, - url: promote_project_milestone_path(@milestone.project, @milestone), - container: 'body' }, - disabled: true, - type: 'button' } - = _('Promote') - #promote-milestone-modal - - - if @milestone.active? - = link_to _('Close milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :close }), method: :put, class: 'btn btn-close btn-nr btn-grouped' - - else - = link_to _('Reopen milestone'), project_milestone_path(@project, @milestone, milestone: {state_event: :activate }), method: :put, class: 'btn btn-reopen btn-nr btn-grouped' - - = render 'shared/milestones/delete_button' - - %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: '#' } - = icon('angle-double-left') - -.detail-page-description.milestone-detail - %h2.title.qa-milestone-title - = markdown_field(@milestone, :title) - - %div - - if @milestone.description.present? - .description.md - = markdown_field(@milestone, :description) += render 'shared/milestones/header', milestone: @milestone += render 'shared/milestones/description', milestone: @milestone = render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index ee82d68d398..a6978cba495 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -3,10 +3,12 @@ .form-group = f.label :auth_method, _('Authentication method'), class: 'label-bold' - = f.select :auth_method, - options_for_select(auth_options, mirror.auth_method), - {}, { class: "form-control js-mirror-auth-type qa-authentication-method" } - = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" + .select-wrapper + = f.select :auth_method, + options_for_select(auth_options, mirror.auth_method), + {}, { class: "form-control select-control js-mirror-auth-type qa-authentication-method" } + = icon('chevron-down') + = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" .form-group .collapse.js-well-changing-auth diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index b49f1d9315e..dd794e03f48 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,5 +1,7 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control js-mirror-direction qa-mirror-direction', disabled: true + .select-wrapper + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control select-control js-mirror-direction qa-mirror-direction', disabled: true + = icon('chevron-down') = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 178f0acc5b9..08dcba2afd7 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -13,5 +13,11 @@ - @project.pages_domains.each do |domain| %p = external_link(domain.url, domain.url) + - unless @project.public_pages? + .card-footer.alert-warning + - help_page = help_page_path('/user/project/pages/pages_access_control') + - link_start = '<a href="%{url}" target="_blank" class="alert-link" rel="noopener noreferrer">'.html_safe % { url: help_page } + - link_end = '</a>'.html_safe + = s_('GitLabPages|Access Control is enabled for this Pages website; only authorized users will be able to access it. To make your website publicly available, navigate to your project\'s %{strong_start}Settings > General > Visibility%{strong_end} and select %{strong_start}Everyone%{strong_end} in pages section. Read the %{link_start}documentation%{link_end} for more information.').html_safe % { link_start: link_start, link_end: link_end, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } .card-footer.alert-primary = s_('GitLabPages|It may take up to 30 minutes before the site is available after the first deployment.') diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index b05491f2c6e..4676c7399f1 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -21,11 +21,11 @@ %span.badge.badge-danger = s_('GitLabPages|Expired') %div - = link_to s_('GitLabPages|Details'), project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped" + = link_to s_('GitLabPages|Edit'), edit_project_pages_domain_path(@project, domain), class: "btn btn-sm btn-grouped btn-success btn-inverted" = link_to s_('GitLabPages|Remove'), project_pages_domain_path(@project, domain), data: { confirm: s_('GitLabPages|Are you sure?')}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" - if verification_enabled && domain.unverified? %li.list-group-item.bs-callout-warning - - details_link_start = "<a href='#{project_pages_domain_path(@project, domain)}'>".html_safe + - details_link_start = "<a href='#{edit_project_pages_domain_path(@project, domain)}'>".html_safe - details_link_end = '</a>'.html_safe = s_('GitLabPages|%{domain} is not verified. To learn how to verify ownership, visit your %{link_start}domain details%{link_end}.').html_safe % { domain: domain.domain, link_start: details_link_start, diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index 0e1f281410a..3ec87597849 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -1,23 +1,27 @@ - page_title 'Pages' -%h3.page-title.with-button - = s_('GitLabPages|Pages') +- if @project.pages_enabled? + %h3.page-title.with-button + = s_('GitLabPages|Pages') - - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) - = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do - = s_('GitLabPages|New Domain') + - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) + = link_to new_project_pages_domain_path(@project), class: 'btn btn-success float-right', title: s_('GitLabPages|New Domain') do + = s_('GitLabPages|New Domain') -%p.light - = s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.') -- if Gitlab.config.pages.external_https - = render 'https_only' + %p.light + = s_('GitLabPages|With GitLab Pages you can host your static websites on GitLab. Combined with the power of GitLab CI and the help of GitLab Runner you can deploy static pages for your individual projects, your user or your group.') + - if Gitlab.config.pages.external_https + = render 'https_only' -%hr.clearfix + %hr.clearfix -= render 'access' -= render 'use' -- if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https - = render 'list' + = render 'access' + = render 'use' + - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render 'list' + - else + = render 'no_domains' + = render 'destroy' - else - = render 'no_domains' -= render 'destroy' + .bs-callout.bs-callout-warning + = s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings > General > Visibility%{strong_end} page.').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml index 42631fca5e8..92d30e0b056 100644 --- a/app/views/projects/pages_domains/_certificate.html.haml +++ b/app/views/projects/pages_domains/_certificate.html.haml @@ -1,18 +1,63 @@ -- if @domain.auto_ssl_enabled? - - if @domain.enabled? - - if @domain.certificate_text - %pre - = @domain.certificate_text - - else - .bs-callout.bs-callout-info - = _("GitLab is obtaining a Let's Encrypt SSL certificate for this domain. This process can take some time. Please try again later.") +- auto_ssl_available = ::Gitlab::LetsEncrypt.enabled? +- auto_ssl_enabled = @domain.auto_ssl_enabled? +- auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled +- has_user_defined_certificate = @domain.certificate && @domain.certificate_user_provided? + +- if auto_ssl_available + .form-group.border-section + .row + .col-sm-2 + = _('Certificate') + .col-sm-10.js-auto-ssl-toggle-container + %label{ for: "pages_domain_auto_ssl_enabled_button" } + - lets_encrypt_link_url = "https://letsencrypt.org/" + - lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url } + - lets_encrypt_link_end = "</a>".html_safe + = _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end } + %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button", + class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}", + "aria-label": _("Automatic certificate management using Let's Encrypt") } + = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" + %span.toggle-icon + = sprite_icon("status_success_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-checked") + = sprite_icon("status_failed_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-unchecked") + %p.text-secondary.mt-3 + - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md") + - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } + - docs_link_end = "</a>".html_safe + = _("Let's Encrypt is a free, automated, and open certificate authority (CA) that gives digital certificates in order to enable HTTPS (SSL/TLS) for websites. Learn more about Let's Encrypt configuration by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end } + +.form-group.border-section.js-shown-unless-auto-ssl{ class: ("d-none" if auto_ssl_available_and_enabled) } + - if has_user_defined_certificate + .row + .col-sm-10.offset-sm-2 + .card + .card-header + = _('Certificate') + .d-flex.justify-content-between.align-items-center.p-3 + %span + = @domain.subject || _('missing') + = link_to _('Remove'), + clean_certificate_project_pages_domain_path(@project, @domain), + data: { confirm: _('Are you sure?') }, + class: 'btn btn-remove btn-sm', + method: :delete - else - .bs-callout.bs-callout-warning - = _("A Let's Encrypt SSL certificate can not be obtained until your domain is verified.") -- else - - if @domain.certificate_text - %pre - = @domain.certificate_text - - else - .light - = _("missing") + .row + .col-sm-10.offset-sm-2 + = f.label :user_provided_certificate, _("Certificate (PEM)") + = f.text_area :user_provided_certificate, + rows: 5, + class: "form-control js-enabled-unless-auto-ssl", + disabled: auto_ssl_available_and_enabled + %span.help-inline.text-muted= _("Upload a certificate for your domain with all intermediates") + .row + .col-sm-10.offset-sm-2 + = f.label :user_provided_key, _("Key (PEM)") + = f.text_area :user_provided_key, + rows: 5, + class: "form-control js-enabled-unless-auto-ssl", + disabled: auto_ssl_available_and_enabled + %span.help-inline.text-muted= _("Upload a private key for your certificate") + += render 'lets_encrypt_callout', auto_ssl_available_and_enabled: auto_ssl_available_and_enabled diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml new file mode 100644 index 00000000000..e4e590f0a98 --- /dev/null +++ b/app/views/projects/pages_domains/_dns.html.haml @@ -0,0 +1,33 @@ +- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? +- dns_record = "#{@domain.domain} CNAME #{@domain.project.pages_subdomain}.#{Settings.pages.host}." + +.form-group.border-section + .row + .col-sm-2 + = _("DNS") + .col-sm-10 + .input-group + = text_field_tag :domain_dns, dns_record , class: "monospace js-select-on-focus form-control", readonly: true + .input-group-append + = clipboard_button(target: '#domain_dns', class: 'btn-default input-group-text d-none d-sm-block') + %p.form-text.text-muted + = _("To access this domain create a new DNS record") +- if verification_enabled + - verification_record = "#{@domain.verification_domain} TXT #{@domain.keyed_verification_code}" + .form-group.border-section + .row + .col-sm-2 + = _("Verification status") + .col-sm-10 + .status-badge + - text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success'] + .badge{ class: status } + = text + = link_to sprite_icon("redo"), verify_project_pages_domain_path(@project, @domain), method: :post, class: "btn has-tooltip", title: _("Retry verification") + .input-group + = text_field_tag :domain_verification, verification_record, class: "monospace js-select-on-focus form-control", readonly: true + .input-group-append + = clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') + %p.form-text.text-muted + - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')) + = _("To %{link_to_help} of your domain, add the above key to a TXT record within to your DNS configuration.").html_safe % { link_to_help: link_to_help } diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml index 4aa1e574d93..e06dab9be06 100644 --- a/app/views/projects/pages_domains/_form.html.haml +++ b/app/views/projects/pages_domains/_form.html.haml @@ -3,62 +3,25 @@ - @domain.errors.full_messages.each do |msg| = msg -.form-group.row - .col-sm-2.col-form-label - = f.label :domain, _("Domain") - .col-sm-10 - = f.text_field :domain, required: true, autocomplete: "off", class: "form-control", disabled: @domain.persisted? - -- if Gitlab.config.pages.external_https - - - auto_ssl_available = ::Gitlab::LetsEncrypt.enabled? - - auto_ssl_enabled = @domain.auto_ssl_enabled? - - auto_ssl_available_and_enabled = auto_ssl_available && auto_ssl_enabled - - - if auto_ssl_available - .form-group.row - .col-sm-2.col-form-label - %label{ for: "pages_domain_auto_ssl_enabled_button" } - - lets_encrypt_link_url = "https://letsencrypt.org/" - - lets_encrypt_link_start = "<a href=\"%{lets_encrypt_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { lets_encrypt_link_url: lets_encrypt_link_url } - - lets_encrypt_link_end = "</a>".html_safe - = _("Automatic certificate management using %{lets_encrypt_link_start}Let's Encrypt%{lets_encrypt_link_end}").html_safe % { lets_encrypt_link_start: lets_encrypt_link_start, lets_encrypt_link_end: lets_encrypt_link_end } - - .col-sm-10.js-auto-ssl-toggle-container - %button{ type: "button", id: "pages_domain_auto_ssl_enabled_button", - class: "js-project-feature-toggle project-feature-toggle mt-2 #{"is-checked" if auto_ssl_available_and_enabled}", - "aria-label": _("Automatic certificate management using Let's Encrypt") } - = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" - %span.toggle-icon - = sprite_icon("status_success_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-checked") - = sprite_icon("status_failed_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-unchecked") - %p.text-secondary.mt-3 - - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md") - - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } - - docs_link_end = "</a>".html_safe - = _("Let's Encrypt is a free, automated, and open certificate authority (CA) that gives digital certificates in order to enable HTTPS (SSL/TLS) for websites. Learn more about Let's Encrypt configuration by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end } - - .js-shown-unless-auto-ssl{ class: ("d-none" if auto_ssl_available_and_enabled) } - .form-group.row - .col-sm-2.col-form-label - = f.label :user_provided_certificate, _("Certificate (PEM)") +.form-group.border-section + .row + - if @domain.persisted? + .col-sm-2 + = _("Domain") .col-sm-10 - = f.text_area :user_provided_certificate, - rows: 5, - class: "form-control js-enabled-unless-auto-ssl", - disabled: auto_ssl_available_and_enabled - %span.help-inline.text-muted= _("Upload a certificate for your domain with all intermediates") - - .form-group.row - .col-sm-2.col-form-label - = f.label :user_provided_key, _("Key (PEM)") + = external_link(@domain.url, @domain.url) + - else + .col-sm-2 + = f.label :domain, _("Domain") .col-sm-10 - = f.text_area :user_provided_key, - rows: 5, - class: "form-control js-enabled-unless-auto-ssl", - disabled: auto_ssl_available_and_enabled - %span.help-inline.text-muted= _("Upload a private key for your certificate") + .input-group + = f.text_field :domain, required: true, autocomplete: "off", class: "form-control" +- if @domain.persisted? + = render 'dns' + +- if Gitlab.config.pages.external_https + = render 'certificate', f: f - else - .nothing-here-block + .border-section.nothing-here-block = _("Support for custom certificates is disabled. Ask your system's administrator to enable it.") diff --git a/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml new file mode 100644 index 00000000000..d6406a78fca --- /dev/null +++ b/app/views/projects/pages_domains/_lets_encrypt_callout.html.haml @@ -0,0 +1,13 @@ +- if @domain.enabled? + - if @domain.auto_ssl_enabled && !@domain.certificate + .form-group.border-section.js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) } + .row + .col-sm-10.offset-sm-2 + .bs-callout.bs-callout-info.mt-0 + = _("GitLab is obtaining a Let's Encrypt SSL certificate for this domain. This process can take some time. Please try again later.") +- else + .form-group.border-section.js-shown-if-auto-ssl{ class: ("d-none" unless auto_ssl_available_and_enabled) } + .row + .col-sm-10.offset-sm-2 + .bs-callout.bs-callout-warning.mt-0 + = _("A Let's Encrypt SSL certificate can not be obtained until your domain is verified.") diff --git a/app/views/projects/pages_domains/edit.html.haml b/app/views/projects/pages_domains/edit.html.haml index 7c0777e5496..a08be65d7e4 100644 --- a/app/views/projects/pages_domains/edit.html.haml +++ b/app/views/projects/pages_domains/edit.html.haml @@ -1,12 +1,21 @@ - add_to_breadcrumbs _("Pages"), project_pages_path(@project) - breadcrumb_title @domain.domain - page_title @domain.domain + +- verification_enabled = Gitlab::CurrentSettings.pages_domain_verification_enabled? + +- if verification_enabled && @domain.unverified? + = content_for :flash_message do + .alert.alert-warning + .container-fluid.container-limited + = _("This domain is not verified. You will need to verify ownership before access is enabled.") + %h3.page-title - = @domain.domain + = _('Pages Domain') = render 'projects/pages_domains/helper_text' -%hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } - .form-actions + .form-actions.d-flex.justify-content-between = f.submit _('Save Changes'), class: "btn btn-success" + = link_to _('Cancel'), project_pages_path(@project), class: 'btn btn-default btn-inverse' diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index e23ccb5d4c6..3210bfe9231 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -3,7 +3,6 @@ %h3.page-title = _("New Pages Domain") = render 'projects/pages_domains/helper_text' -%hr.clearfix %div = form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index 33837e21c8d..8eec3d51835 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -58,4 +58,4 @@ %td = _("Certificate") %td - = render 'certificate' + = render 'lets_encrypt_callout', auto_ssl_available_and_enabled: false diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index 8c3518e3a29..4d8cba5168d 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,3 +1,5 @@ +- test_reports_enabled = Feature.enabled?(:junit_pipeline_view) + .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %li.js-pipeline-tab-link @@ -12,6 +14,11 @@ = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do = _('Failed Jobs') %span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count + - if test_reports_enabled + %li.js-tests-tab-link + = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do + = s_('TestReports|Tests') + %span.badge.badge-pill= pipeline.test_reports.total_count = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content @@ -32,10 +39,6 @@ %th = render partial: "projects/stage/stage", collection: pipeline.legacy_stages, as: :stage - - elsif pipeline.project.builds_enabled? && !pipeline.ci_yaml_file - .bs-callout.bs-callout-warning - = _("%{gitlab_ci_yml} not found in this commit") % { gitlab_ci_yml: ".gitlab-ci.yml" } - - if @pipeline.failed_builds.present? #js-tab-failures.build-failures.tab-pane.build-page %table.table.responsive-table.ci-table.responsive-table-sm-rounded @@ -71,4 +74,7 @@ %pre.build-trace.build-trace-rounded %code.bash.js-build-output = build_summary(build) + + #js-tab-tests.tab-pane + #js-pipeline-tests-detail = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index bfcaa09ae8c..a3e46a0939c 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -23,6 +23,13 @@ %label = s_('Pipeline|Variables') %ul.ci-variable-list + - if params[:var] + - params[:var].each do |variable| + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable + - if params[:file_var] + - params[:file_var].each do |variable| + - variable.push("file") + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true .form-text.text-muted = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 2b2133b8296..f0b3ab24ea0 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -20,4 +20,5 @@ - else = render "projects/pipelines/with_tabs", pipeline: @pipeline -.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } +.js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json), + test_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json) } } diff --git a/app/views/projects/protected_branches/shared/_branches_list.html.haml b/app/views/projects/protected_branches/shared/_branches_list.html.haml index 9dff251101b..f07de81d7fd 100644 --- a/app/views/projects/protected_branches/shared/_branches_list.html.haml +++ b/app/views/projects/protected_branches/shared/_branches_list.html.haml @@ -5,6 +5,7 @@ %p.settings-message.text-center = s_("ProtectedBranch|There are currently no protected branches, protect a branch with the form above.") - else + .flash-container %table.table.table-bordered %colgroup %col{ width: "20%" } @@ -27,8 +28,6 @@ - if can_admin_project %th %tbody - %tr - %td.flash-container{ colspan: 5 } = yield = paginate @protected_branches, theme: 'gitlab' diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 959a2423e02..582f3d6fce4 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -6,7 +6,6 @@ - hide_class = 'd-none' if @service.activated? != value %span.js-service-active-status{ class: hide_class, data: { value: value.to_s } } = boolean_to_icon value - %p= #{@service.description}. - if @service.respond_to?(:detailed_description) %p= @service.detailed_description diff --git a/app/views/projects/services/_index.html.haml b/app/views/projects/services/_index.html.haml index 7748a7a6a8e..3f33d72d3ec 100644 --- a/app/views/projects/services/_index.html.haml +++ b/app/views/projects/services/_index.html.haml @@ -21,7 +21,7 @@ %td{ "aria-label" => (service.activated? ? s_("ProjectService|%{service_title}: status on") : s_("ProjectService|%{service_title}: status off")) % { service_title: service.title } } = boolean_to_icon service.activated? %td - = link_to edit_project_service_path(@project, service.to_param) do + = link_to edit_project_service_path(@project, service.to_param), { data: { qa_selector: "#{service.title.downcase.gsub(/[\s\(\)]/,'_')}_link" } } do %strong= service.title %td.d-none.d-sm-block = service.description diff --git a/app/views/projects/services/edit.html.haml b/app/views/projects/services/edit.html.haml index fc20bc52d1c..1e7903535c6 100644 --- a/app/views/projects/services/edit.html.haml +++ b/app/views/projects/services/edit.html.haml @@ -1,6 +1,7 @@ -- breadcrumb_title s_("ProjectService|Integrations") +- breadcrumb_title @service.title - page_title @service.title, s_("ProjectService|Services") - add_to_breadcrumbs(s_("ProjectService|Settings"), edit_project_path(@project)) +- add_to_breadcrumbs(s_("ProjectService|Integrations"), namespace_project_settings_integrations_path) = render 'deprecated_message' if @service.deprecation_message diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index 66ed1cadf6a..ea815be23c1 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -98,7 +98,7 @@ %span.input-group-append .input-group-text / %p.form-text.text-muted - = _("A regular expression that will be used to find the test coverage output in the job trace. Leave blank to disable") + = _("A regular expression that will be used to find the test coverage output in the job log. Leave blank to disable") = link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'test-coverage-parsing'), target: '_blank' .bs-callout.bs-callout-info %p= _("Below are examples of regex for existing tools:") diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 87000e8270b..862db23e856 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -37,7 +37,8 @@ %button.btn.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = _("Register and see your runners for this project.") + = _("Runners are processes that pick up and execute jobs for GitLab. Here you can register and see your Runners for this project.") + = link_to s_('More information'), help_page_path('ci/runners/README') .settings-content = render 'projects/runners/index' diff --git a/app/views/projects/settings/operations/_grafana_integration.html.haml b/app/views/projects/settings/operations/_grafana_integration.html.haml new file mode 100644 index 00000000000..cd5b5abd9ce --- /dev/null +++ b/app/views/projects/settings/operations/_grafana_integration.html.haml @@ -0,0 +1,2 @@ +.js-grafana-integration{ data: { operations_settings_endpoint: project_settings_operations_path(@project), + grafana_integration: { url: grafana_integration_url, token: grafana_integration_token, enabled: grafana_integration_enabled?.to_s } } } diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml index 0a7a155bc12..3c955e5f558 100644 --- a/app/views/projects/settings/operations/show.html.haml +++ b/app/views/projects/settings/operations/show.html.haml @@ -5,4 +5,5 @@ = render_if_exists 'projects/settings/operations/incidents' = render 'projects/settings/operations/error_tracking' = render 'projects/settings/operations/external_dashboard' += render 'projects/settings/operations/grafana_integration' = render_if_exists 'projects/settings/operations/tracing' diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index b58af545439..c5653c3dd5a 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -6,7 +6,7 @@ = render partial: 'flash_messages', locals: { project: @project } -- if !@project.empty_repo? && can?(current_user, :download_code, @project) +- if !@project.empty_repo? && can?(current_user, :download_code, @project) && !vue_file_list_enabled? - signatures_path = project_signatures_path(@project, @project.default_branch) .js-signature-container{ data: { 'signatures-path': signatures_path } } diff --git a/app/views/projects/tree/_readme.html.haml b/app/views/projects/tree/_readme.html.haml index 4f6c7e1f9a6..fef019e1b69 100644 --- a/app/views/projects/tree/_readme.html.haml +++ b/app/views/projects/tree/_readme.html.haml @@ -1,5 +1,5 @@ - if readme.rich_viewer - %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-hide-on-navigation" if vue_file_list_enabled?)] } + %article.file-holder.readme-holder{ id: 'readme', class: [("limited-width-container" unless fluid_layout), ("js-show-on-root" if vue_file_list_enabled?)] } .js-file-title.file-title = blob_icon readme.mode, readme.name = link_to project_blob_path(@project, tree_join(@ref, readme.path)) do diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 38422d4533d..127734ddfd7 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -77,15 +77,21 @@ .tree-controls = render_if_exists 'projects/tree/lock_link' - = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' + - if vue_file_list_enabled? + #js-tree-history-link.d-inline-block{ data: { history_link: project_commits_path(@project, @ref) } } + - else + = link_to s_('Commits|History'), project_commits_path(@project, @id), class: 'btn' = render 'projects/find_file_link' - if can_create_mr_from_fork = succeed " " do - if can_collaborate || current_user&.already_forked?(@project) - = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do - = _('Web IDE') + - if vue_file_list_enabled? + #js-tree-web-ide-link.d-inline-block + - else + = link_to ide_edit_path(@project, @ref, @path), class: 'btn btn-default qa-web-ide-button' do + = _('Web IDE') - else = link_to '#modal-confirm-fork', class: 'btn btn-default qa-web-ide-button', data: { target: '#modal-confirm-fork', toggle: 'modal'} do = _('Web IDE') diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 39b29a20df6..65f5bc31d2e 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -6,7 +6,8 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") -.js-signature-container{ data: { 'signatures-path': signatures_path } } +- unless vue_file_list_enabled? + .js-signature-container{ data: { 'signatures-path': signatures_path } } = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml index 02ab974ecc0..7b92f5070df 100644 --- a/app/views/registrations/welcome.html.haml +++ b/app/views/registrations/welcome.html.haml @@ -1,10 +1,10 @@ -- content_for(:page_title, _('Welcome to GitLab<br>%{username}!' % { username: html_escape(current_user.username) }).html_safe) +- content_for(:page_title, _('Welcome to GitLab @%{username}!') % { username: current_user.username }) - max_name_length = 128 .text-center.mb-3 - = _('In order to tailor your experience with GitLab<br>we would like to know a bit more about you.').html_safe + = _('In order to tailor your experience with GitLab we<br>would like to know a bit more about you.').html_safe .signup-box.p-3.mb-2 .signup-body - = form_for(current_user, url: users_sign_up_update_role_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| + = form_for(current_user, url: users_sign_up_update_registration_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| .devise-errors.mt-0 = render 'devise/shared/error_messages', resource: current_user .name.form-group @@ -13,5 +13,14 @@ .form-group = f.label :role, _('Role'), class: 'label-bold' = f.select :role, ::User.roles.keys.map { |role| [role.titleize, role] }, {}, class: 'form-control' + .form-group + = f.label :setup_for_company, _('Are you setting up GitLab for a company?'), class: 'label-bold' + .d-flex.justify-content-center + .w-25 + = f.radio_button :setup_for_company, true + = f.label :setup_for_company, _('Yes'), value: 'true' + .w-25 + = f.radio_button :setup_for_company, false + = f.label :setup_for_company, _('No'), value: 'false' .submit-container.mt-3 = f.submit _('Get started!'), class: 'btn-register btn btn-block mb-0 p-2' diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index eae2a491ceb..84198489e41 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -1,5 +1,5 @@ - users = capture_haml do - - if search_tabs?(:members) + - if show_user_search_tab? = search_filter_link 'users', _("Users") .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index de9947528cf..629a5a045b1 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -1,6 +1,7 @@ - if @search_objects.to_a.empty? = render partial: "search/results/empty" = render_if_exists 'shared/promotions/promote_advanced_search' + = render_if_exists 'search/form_revert_to_basic' - else .row-content-block.d-md-flex.text-left.align-items-center - unless @search_objects.is_a?(Kaminari::PaginatableWithoutCount) diff --git a/app/views/search/results/_blob.html.haml b/app/views/search/results/_blob.html.haml index bdad07f36d1..4fb72b26955 100644 --- a/app/views/search/results/_blob.html.haml +++ b/app/views/search/results/_blob.html.haml @@ -2,6 +2,6 @@ - return unless project - blob = parse_search_result(blob) -- blob_link = project_blob_path(project, tree_join(blob.ref, blob.filename)) +- blob_link = project_blob_path(project, tree_join(blob.ref, blob.path)) -= render partial: 'search/results/blob_data', locals: { blob: blob, project: project, file_name: blob.filename, blob_link: blob_link } += render partial: 'search/results/blob_data', locals: { blob: blob, project: project, path: blob.path, blob_link: blob_link } diff --git a/app/views/search/results/_blob_data.html.haml b/app/views/search/results/_blob_data.html.haml index 36b6ea7bd37..01e42224428 100644 --- a/app/views/search/results/_blob_data.html.haml +++ b/app/views/search/results/_blob_data.html.haml @@ -4,7 +4,7 @@ = link_to blob_link do %i.fa.fa-file %strong - = search_blob_title(project, file_name) + = search_blob_title(project, path) - if blob.data .file-content.code.term{ data: { qa_selector: 'file_text_content' } } = render 'shared/file_highlight', blob: blob, first_line_number: blob.startline diff --git a/app/views/search/results/_snippet_blob.html.haml b/app/views/search/results/_snippet_blob.html.haml index f17dae0a94c..37f4efee9d2 100644 --- a/app/views/search/results/_snippet_blob.html.haml +++ b/app/views/search/results/_snippet_blob.html.haml @@ -1,6 +1,7 @@ - snippet_blob = chunk_snippet(snippet_blob, @search_term) - snippet = snippet_blob[:snippet_object] - snippet_chunks = snippet_blob[:snippet_chunks] +- snippet_path = reliable_snippet_path(snippet) .search-result-row %span @@ -11,7 +12,6 @@ = snippet.author_name %span.light= time_ago_with_tooltip(snippet.created_at) %h4.snippet-title - - snippet_path = reliable_snippet_path(snippet) .file-holder .js-file-title.file-title = link_to snippet_path do diff --git a/app/views/search/results/_snippet_title.html.haml b/app/views/search/results/_snippet_title.html.haml index 1e01088d9e6..7280146720e 100644 --- a/app/views/search/results/_snippet_title.html.haml +++ b/app/views/search/results/_snippet_title.html.haml @@ -2,10 +2,7 @@ %h4.snippet-title.term = link_to reliable_snippet_path(snippet_title) do = truncate(snippet_title.title, length: 60) - - if snippet_title.private? - %span.badge.badge-gray - %i.fa.fa-lock - = _("private") + = snippet_badge(snippet_title) %span.cgray.monospace.tiny.float-right.term = snippet_title.file_name diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index b351ecd4edf..9afed2bbecc 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -2,4 +2,4 @@ - wiki_blob = parse_search_result(wiki_blob) - wiki_blob_link = project_wiki_path(project, wiki_blob.basename) -= render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, file_name: wiki_blob.filename, blob_link: wiki_blob_link } += render partial: 'search/results/blob_data', locals: { blob: wiki_blob, project: project, path: wiki_blob.path, blob_link: wiki_blob_link } diff --git a/app/views/shared/_clone_panel.html.haml b/app/views/shared/_clone_panel.html.haml index cb834878276..3e805189055 100644 --- a/app/views/shared/_clone_panel.html.haml +++ b/app/views/shared/_clone_panel.html.haml @@ -22,6 +22,3 @@ .input-group-append = clipboard_button(target: '#project_clone', title: _("Copy URL"), class: "input-group-text btn-default btn-clipboard") - = render_if_exists 'shared/geo_modal_button' - -= render_if_exists 'shared/geo_modal', project: project diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml index 606d0f241aa..a7ad6d6f2c4 100644 --- a/app/views/shared/_field.html.haml +++ b/app/views/shared/_field.html.haml @@ -16,7 +16,7 @@ = form.label name, title, class: "col-form-label col-sm-2" .col-sm-10 - if type == 'text' - = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled + = form.text_field name, class: "form-control", placeholder: placeholder, required: required, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - elsif type == 'textarea' = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required, disabled: disabled - elsif type == 'checkbox' @@ -24,6 +24,6 @@ - elsif type == 'select' = form.select name, options_for_select(choices, value ? value : default_choice), {}, { class: "form-control", disabled: disabled} - elsif type == 'password' - = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, disabled: disabled + = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, disabled: disabled, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - if help %span.form-text.text-muted= help diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 959792718ca..9a65981ed58 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -22,11 +22,16 @@ - if parent %strong= parent.full_path + '/' = f.hidden_field :parent_id - = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control', + = f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, title: _('Please choose a group URL with no special characters.'), "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" + %p.validation-error.gl-field-error.field-validation.hide + = _('Group path is already taken. Suggestions: ') + %span.gl-path-suggestions + %p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.') + %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...') - if @group.persisted? .alert.alert-warning.prepend-top-10 diff --git a/app/views/shared/_mobile_clone_panel.html.haml b/app/views/shared/_mobile_clone_panel.html.haml index 1e6b6f7c79b..2887acf7cd7 100644 --- a/app/views/shared/_mobile_clone_panel.html.haml +++ b/app/views/shared/_mobile_clone_panel.html.haml @@ -4,7 +4,7 @@ .btn-group.mobile-git-clone.js-mobile-git-clone.btn-block = clipboard_button(button_text: default_clone_label, text: default_url_to_repo(project), hide_button_icon: true, class: "btn-primary flex-fill bold justify-content-center input-group-text clone-dropdown-btn js-clone-dropdown-label") - %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle{ type: "button", data: { toggle: "dropdown" } } + %button.btn.btn-primary.dropdown-toggle.js-dropdown-toggle.flex-grow-0.d-flex-center{ type: "button", data: { toggle: "dropdown" } } = sprite_icon("arrow-down", css_class: "dropdown-btn-icon icon") %ul.dropdown-menu.dropdown-menu-selectable.dropdown-menu-right.clone-options-dropdown{ data: { dropdown: true } } - if ssh_enabled? diff --git a/app/views/shared/_personal_access_tokens_form.html.haml b/app/views/shared/_personal_access_tokens_form.html.haml index 1d96feda3b0..ca0b473addf 100644 --- a/app/views/shared/_personal_access_tokens_form.html.haml +++ b/app/views/shared/_personal_access_tokens_form.html.haml @@ -19,7 +19,6 @@ = f.label :expires_at, _('Expires at'), class: 'label-bold' .input-icon-wrapper = f.text_field :expires_at, class: "datepicker form-control", placeholder: 'YYYY-MM-DD' - = icon('calendar', { class: 'input-icon-right' }) .form-group = f.label :scopes, _('Scopes'), class: 'label-bold' diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 6fa61c15493..627a1eb6eae 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -12,7 +12,7 @@ .form-group.row = form.label :active, "Active", class: "col-form-label col-sm-2" .col-sm-10 - = form.check_box :active, disabled: disable_fields_service?(@service) + = form.check_box :active, disabled: disable_fields_service?(@service), data: { qa_selector: 'active_checkbox' } - if @service.configurable_events.present? .form-group.row diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index be78fd0ccfb..9db6184ebca 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -2,6 +2,7 @@ - model = local_assigns.fetch(:model) - form = local_assigns.fetch(:form) +- placeholder = model.is_a?(MergeRequest) ? _('Describe the goal of the changes and what reviewers should be aware of.') : _('Write a comment or drag your files here…') - supports_quick_actions = model.new_record? - if supports_quick_actions @@ -16,7 +17,7 @@ = render layout: 'projects/md_preview', locals: { url: preview_url, referenced_users: true } do = render 'projects/zen', f: form, attr: :description, classes: 'note-textarea qa-issuable-form-description rspec-issuable-form-description', - placeholder: "Write a comment or drag your files here…", + placeholder: placeholder, supports_quick_actions: supports_quick_actions = render 'shared/notes/hints', supports_quick_actions: supports_quick_actions .clearfix diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 5e2b5f95ee3..0fb23adc31f 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -12,6 +12,8 @@ = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), target: "_blank", rel: 'noopener noreferrer' and make sure your changes will not unintentionally remove theirs += render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form + .form-group.row = form.label :title, class: 'col-form-label col-sm-2' @@ -34,8 +36,6 @@ = render_if_exists 'shared/issuable/approvals', issuable: issuable, presenter: presenter, form: form -= render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form - = render 'shared/issuable/form/merge_params', issuable: issuable = render 'shared/issuable/form/contribution', issuable: issuable, form: form diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 9d580930fb8..d341520e4a2 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -5,155 +5,170 @@ - user_can_admin_list = board && can?(current_user, :admin_list, board.resource_parent) .issues-filters{ class: ("w-100" if type == :boards_modal) } - .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-md-row{ class: block_css_class, "v-pre" => type == :boards_modal } - - if type == :boards - = render "shared/boards/switcher", board: board - = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do - - if params[:search].present? - = hidden_field_tag :search, params[:search] - - if @can_bulk_update - .check-all-holder.d-none.d-sm-block.hidden - = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" - .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row - .filtered-search-box - - if type != :boards_modal && type != :boards - = dropdown_tag(_('Recent searches'), - options: { wrapper_class: "filtered-search-history-dropdown-wrapper", - toggle_class: "filtered-search-history-dropdown-toggle-button", - dropdown_class: "filtered-search-history-dropdown", - content_class: "filtered-search-history-dropdown-content" }) do - .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } } - .filtered-search-box-input-container.droplab-dropdown - .scroll-container - %ul.tokens-container.list-unstyled - %li.input-token - %input.form-control.filtered-search{ search_filter_input_options(type) } - #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { action: 'submit' } } - %button.btn.btn-link{ type: 'button' } - = sprite_icon('search') - %span - = _('Press Enter or click to search') - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link{ type: 'button' } - -# Encapsulate static class name `{{icon}}` inside #{} to bypass - -# haml lint's ClassAttributeWithStaticValue - %svg - %use{ 'xlink:href': "#{'{{icon}}'}" } - %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} - #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu - - if current_user + .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-lg-row{ class: block_css_class, "v-pre" => type == :boards_modal } + .d-flex.flex-column.flex-md-row.flex-grow-1.mb-lg-0.mb-md-2.mb-sm-0 + - if type == :boards + = render "shared/boards/switcher", board: board + = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do + - if params[:search].present? + = hidden_field_tag :search, params[:search] + - if @can_bulk_update + .check-all-holder.d-none.d-sm-block.hidden + = check_box_tag "check-all-issues", nil, false, class: "check-all-issues left" + .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row + .filtered-search-box + - if type != :boards_modal && type != :boards + = dropdown_tag(_('Recent searches'), + options: { wrapper_class: "filtered-search-history-dropdown-wrapper", + toggle_class: "filtered-search-history-dropdown-toggle-button", + dropdown_class: "filtered-search-history-dropdown", + content_class: "filtered-search-history-dropdown-content" }) do + .js-filtered-search-history-dropdown{ data: { full_path: search_history_storage_prefix } } + .filtered-search-box-input-container.droplab-dropdown + .scroll-container + %ul.tokens-container.list-unstyled + %li.input-token + %input.form-control.filtered-search{ search_filter_input_options(type) } + #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown %ul{ data: { dropdown: true } } - = render 'shared/issuable/user_dropdown_item', - user: current_user - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - = render 'shared/issuable/user_dropdown_item', - user: User.new(username: '{{username}}', name: '{{name}}'), - avatar: { lazy: true, url: '{{avatar_url}}' } - #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.divider.droplab-item-ignore + %li.filter-dropdown-item{ data: { action: 'submit' } } + %button.btn.btn-link{ type: 'button' } + = sprite_icon('search') + %span + = _('Press Enter or click to search') + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link{ type: 'button' } + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %svg + %use{ 'xlink:href': "#{'{{icon}}'}" } + %span.js-filter-hint + {{hint}} + %span.js-filter-tag.dropdown-light-content + {{tag}} + #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu - if current_user + %ul{ data: { dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + = render 'shared/issuable/user_dropdown_item', + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } + #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + - if current_user + = render 'shared/issuable/user_dropdown_item', + user: current_user + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } = render 'shared/issuable/user_dropdown_item', - user: current_user - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - = render 'shared/issuable/user_dropdown_item', - user: User.new(username: '{{username}}', name: '{{name}}'), - avatar: { lazy: true, url: '{{avatar_url}}' } - = render_if_exists 'shared/issuable/approver_dropdown' - #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.filter-dropdown-item{ data: { value: 'Upcoming' } } - %button.btn.btn-link{ type: 'button' } - = _('Upcoming') - %li.filter-dropdown-item{ data: { value: 'Started' } } - %button.btn.btn-link{ type: 'button' } - = _('Started') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value{ type: 'button' } - {{title}} - #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link{ type: 'button' } - %span.dropdown-label-box{ style: 'background: {{color}}' } - %span.label-title.js-data-value + user: User.new(username: '{{username}}', name: '{{name}}'), + avatar: { lazy: true, url: '{{avatar_url}}' } + = render_if_exists 'shared/issuable/approver_dropdown' + #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.filter-dropdown-item{ data: { value: 'Upcoming' } } + %button.btn.btn-link{ type: 'button' } + = _('Upcoming') + %li.filter-dropdown-item{ data: { value: 'Started' } } + %button.btn.btn-link{ type: 'button' } + = _('Started') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value{ type: 'button' } + {{title}} + #js-dropdown-release.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value{ type: 'button' } + {{title}} + #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link{ type: 'button' } + %span.dropdown-label-box{ style: 'background: {{color}}' } + %span.label-title.js-data-value + {{title}} + #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'None' } } + %button.btn.btn-link{ type: 'button' } + = _('None') + %li.filter-dropdown-item{ data: { value: 'Any' } } + %button.btn.btn-link{ type: 'button' } + = _('Any') + %li.divider.droplab-item-ignore + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link{ type: 'button' } + %gl-emoji + %span.js-data-value.prepend-left-10 + {{name}} + #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('Yes') + %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('No') + #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('Yes') + %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } + %button.btn.btn-link{ type: 'button' } + = _('No') + #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value.monospace {{title}} - #js-dropdown-my-reaction.filtered-search-input-dropdown-menu.dropdown-menu - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'None' } } - %button.btn.btn-link{ type: 'button' } - = _('None') - %li.filter-dropdown-item{ data: { value: 'Any' } } - %button.btn.btn-link{ type: 'button' } - = _('Any') - %li.divider.droplab-item-ignore - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link{ type: 'button' } - %gl-emoji - %span.js-data-value.prepend-left-10 - {{name}} - #js-dropdown-wip.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('Yes') - %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('No') - #js-dropdown-confidential.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'yes', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('Yes') - %li.filter-dropdown-item{ data: { value: 'no', capitalize: true } } - %button.btn.btn-link{ type: 'button' } - = _('No') - #js-dropdown-target-branch.filtered-search-input-dropdown-menu.dropdown-menu - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value.monospace - {{title}} - = render_if_exists 'shared/issuable/filter_weight', type: type + = render_if_exists 'shared/issuable/filter_weight', type: type - %button.clear-search.hidden{ type: 'button' } - = icon('times') - .filter-dropdown-container.d-flex.flex-column.flex-md-row - - if type == :boards - .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } - - if user_can_admin_list - = render 'shared/issuable/board_create_list_dropdown', board: board - - if @project - #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - #js-toggle-focus-btn - - elsif is_not_boards_modal_or_productivity_analytics - = render 'shared/issuable/sort_dropdown' + %button.clear-search.hidden{ type: 'button' } + = icon('times') + .filter-dropdown-container.d-flex.flex-column.flex-md-row + #js-board-labels-toggle + - if type == :boards + .js-board-config{ data: { can_admin_list: user_can_admin_list, has_scope: board.scoped? } } + - if user_can_admin_list + = render 'shared/issuable/board_create_list_dropdown', board: board + - if @project + #js-add-issues-btn.prepend-left-10{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } + #js-toggle-focus-btn + - elsif is_not_boards_modal_or_productivity_analytics + = render 'shared/issuable/sort_dropdown' diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index c8b2adcf084..2170b88c7c3 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -141,13 +141,7 @@ .js-sidebar-participants-entry-point - if signed_in - - if issuable_sidebar[:project_emails_disabled] - .block.js-emails-disabled - .sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } } - = notification_setting_icon - .hide-collapsed= notification_description(:owner_disabled) - - else - .js-sidebar-subscriptions-entry-point + .js-sidebar-subscriptions-entry-point - project_ref = issuable_sidebar[:reference] .block.project-reference diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index fbc96baa0f7..29ac17c43b9 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -4,21 +4,20 @@ - return unless issuable.is_a?(MergeRequest) - return if issuable.closed_without_fork? -%hr -- if issuable.new_record? - .form-group.row - = form.label :source_branch, class: 'col-form-label col-sm-2' - .col-sm-10 - .issuable-form-select-holder - = form.select(:source_branch, [issuable.source_branch], {}, { class: 'source_branch select2 ref-name', disabled: true }) -.form-group.row - = form.label :target_branch, class: 'col-form-label col-sm-2' - .col-sm-10.target-branch-select-dropdown-container - .issuable-form-select-holder - = form.hidden_field(:target_branch, - { class: 'target_branch js-target-branch-select ref-name', - disabled: issuable.new_record?, - data: { placeholder: "Select branch", endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }}) +- source_title, target_title = format_mr_branch_names(@merge_request) + +.form-group.row.d-flex.gl-pl-3.gl-pr-3.branch-selector + .align-self-center + %span= s_('From %{source_title} into').html_safe % { source_title: "<code>#{source_title}</code>".html_safe } - if issuable.new_record? + %code= target_title - = link_to 'Change branches', mr_change_branches_path(issuable) + = link_to _('Change branches'), mr_change_branches_path(issuable) + - elsif issuable.for_fork? + %code= issuable.target_project_path + ":" + - unless issuable.new_record? + %span.dropdown.prepend-left-5.d-inline-block + = form.hidden_field(:target_branch, + { class: 'target_branch js-target-branch-select ref-name mw-xl', + data: { placeholder: _('Select branch'), endpoint: refs_project_path(@project, sort: 'updated_desc', find: 'branches') }}) +%hr diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index f0c4acdd07f..1b557214e02 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -3,17 +3,17 @@ - return unless issuable.is_a?(MergeRequest) - return if issuable.closed_without_fork? -- if issuable.can_remove_source_branch?(current_user) - .form-group.row - .col-sm-10.offset-sm-2 - .form-check +.form-group.row + .col-sm-2.col-form-label.pt-sm-0 + %label + = _('Merge options') + .col-sm-10 + - if issuable.can_remove_source_branch?(current_user) + .form-check.append-bottom-default = hidden_field_tag 'merge_request[force_remove_source_branch]', '0', id: nil = check_box_tag 'merge_request[force_remove_source_branch]', '1', issuable.force_remove_source_branch?, class: 'form-check-input' = label_tag 'merge_request[force_remove_source_branch]', class: 'form-check-label' do Delete source branch when merge request is accepted. - -.form-group.row - .col-sm-10.offset-sm-2 .form-check = hidden_field_tag 'merge_request[squash]', '0', id: nil = check_box_tag 'merge_request[squash]', '1', issuable.squash, class: 'form-check-input' diff --git a/app/views/shared/members/_access_request_links.html.haml b/app/views/shared/members/_access_request_links.html.haml index eac743b5206..b4b06640bd9 100644 --- a/app/views/shared/members/_access_request_links.html.haml +++ b/app/views/shared/members/_access_request_links.html.haml @@ -4,7 +4,7 @@ - link_text = source.is_a?(Group) ? _('Leave group') : _('Leave project') = link_to link_text, polymorphic_path([:leave, source, :members]), method: :delete, - data: { confirm: leave_confirmation_message(source) }, + data: { confirm: leave_confirmation_message(source), qa_selector: 'leave_group_link' }, class: 'access-request-link js-leave-link' - elsif requester = source.requesters.find_by(user_id: current_user.id) # rubocop: disable CodeReuse/ActiveRecord = link_to _('Withdraw Access Request'), polymorphic_path([:leave, source, :members]), diff --git a/app/views/shared/milestones/_description.html.haml b/app/views/shared/milestones/_description.html.haml new file mode 100644 index 00000000000..5ff110bf94b --- /dev/null +++ b/app/views/shared/milestones/_description.html.haml @@ -0,0 +1,8 @@ +.detail-page-description.milestone-detail + %h2.title + = markdown_field(milestone, :title) + + - if milestone.try(:description).present? + %div + .description.md + = markdown_field(milestone, :description) diff --git a/app/views/shared/milestones/_header.html.haml b/app/views/shared/milestones/_header.html.haml new file mode 100644 index 00000000000..2da857261d1 --- /dev/null +++ b/app/views/shared/milestones/_header.html.haml @@ -0,0 +1,38 @@ +.detail-page-header.milestone-page-header + .status-box{ class: status_box_class(milestone) } + = milestone_status_string(milestone) + + .header-text-content + %span.identifier + %strong + = _('Milestone') + - if milestone.due_date || milestone.start_date + = milestone_date_range(milestone) + + .milestone-buttons + - if can?(current_user, :admin_milestone, @group || @project) + - unless milestone.legacy_group_milestone? + = link_to _('Edit'), edit_milestone_path(milestone), class: 'btn btn-grouped' + + - if milestone.project_milestone? && milestone.project.group + %button.js-promote-project-milestone-button.btn.btn-grouped{ data: { toggle: 'modal', + target: '#promote-milestone-modal', + milestone_title: milestone.title, + group_name: milestone.project.group.name, + url: promote_project_milestone_path(milestone.project, milestone), + container: 'body' }, + disabled: true, + type: 'button' } + = _('Promote') + #promote-milestone-modal + + - if milestone.active? + = link_to _('Close milestone'), update_milestone_path(milestone, { state_event: :close }), method: :put, class: 'btn btn-grouped btn-close' + - else + = link_to _('Reopen milestone'), update_milestone_path(milestone, { state_event: :activate }), method: :put, class: 'btn btn-grouped btn-reopen' + + - unless milestone.legacy_group_milestone? + = render 'shared/milestones/delete_button' + + %button.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ type: 'button' } + = icon('angle-double-left') diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index e99aa3f1ee4..b324f35c338 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -12,8 +12,20 @@ - if @project || milestone.is_a?(GlobalMilestone) || milestone.group_milestone? - if milestone.due_date || milestone.start_date - .milestone-range.append-bottom-5 + .text-tertiary.append-bottom-5 = milestone_date_range(milestone) + - recent_releases, total_count, more_count = recent_releases_with_counts(milestone) + - unless total_count.zero? + .text-tertiary.append-bottom-5.milestone-release-links + = icon('rocket') + = n_('Release', 'Releases', total_count) + - recent_releases.each do |release| + = link_to release.name, project_releases_path(release.project, anchor: release.tag) + - unless release == recent_releases.last + • + - if total_count > recent_releases.count + • + = link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(milestone.project) %div = render('shared/milestone_expired', milestone: milestone) - if milestone.group_milestone? diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 22a6d5e33f0..b6656e6283c 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -138,6 +138,27 @@ Merged: = milestone.merge_requests.merged.count + - if project + - recent_releases, total_count, more_count = recent_releases_with_counts(milestone) + .block.releases + .sidebar-collapsed-icon.has-tooltip{ title: milestone_releases_tooltip_text(milestone), data: { container: 'body', placement: 'left', boundary: 'viewport' } } + %strong + = icon('rocket') + %span= total_count + .title.hide-collapsed= n_('Release', 'Releases', total_count) + .hide-collapsed + - if total_count.zero? + .no-value= _('None') + - else + .font-weight-bold + - recent_releases.each do |release| + = link_to release.name, project_releases_path(project, :anchor => release.tag) + - unless release == recent_releases.last + %span.font-weight-normal • + - if more_count > 0 + %span.font-weight-normal • + = link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(project), class: 'font-weight-normal' + - milestone_ref = milestone.try(:to_reference, full: true) - if milestone_ref.present? .block.reference diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index b877f66c71e..f718c5767d1 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -1,30 +1,22 @@ -- issues_accessible = milestone.is_a?(GlobalMilestone) || can?(current_user, :read_issue, @project) - .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .fade-left= icon('angle-left') .fade-right= icon('angle-right') %ul.nav-links.scrolling-tabs.js-milestone-tabs.nav.nav-tabs - - if issues_accessible - %li.nav-item - = link_to '#tab-issues', class: 'nav-link active', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do - Issues - %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size - %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do - Merge Requests - %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size - - else - %li.nav-item - = link_to '#tab-merge-requests', class: 'nav-link active', 'data-toggle' => 'tab', 'data-endpoint': milestone_merge_request_tab_path(milestone) do - Merge Requests - %span.badge.badge-pill= milestone.merge_requests.size %li.nav-item - = link_to '#tab-participants', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_participants_tab_path(milestone) do - Participants + = link_to '#tab-issues', class: 'nav-link active', data: { toggle: 'tab', show: '.tab-issues-buttons' } do + = _('Issues') + %span.badge.badge-pill= milestone.issues_visible_to_user(current_user).size + %li.nav-item + = link_to '#tab-merge-requests', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'merge_requests') } do + = _('Merge Requests') + %span.badge.badge-pill= milestone.merge_requests_visible_to_user(current_user).size + %li.nav-item + = link_to '#tab-participants', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'participants') } do + = _('Participants') %span.badge.badge-pill= milestone.issue_participants_visible_by_user(current_user).count %li.nav-item - = link_to '#tab-labels', class: 'nav-link', 'data-toggle' => 'tab', 'data-endpoint': milestone_labels_tab_path(milestone) do - Labels + = link_to '#tab-labels', class: 'nav-link', data: { toggle: 'tab', endpoint: milestone_tab_path(milestone, 'labels') } do + = _('Labels') %span.badge.badge-pill= milestone.issue_labels_visible_by_user(current_user).count - issues = milestone.sorted_issues(current_user) @@ -32,16 +24,11 @@ - show_full_project_name = local_assigns.fetch(:show_full_project_name, false) .tab-content.milestone-content - - if issues_accessible - .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } } - = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name - .tab-pane#tab-merge-requests - -# loaded async - = render "shared/milestones/tab_loading" - - else - .tab-pane.active#tab-merge-requests - -# loaded async - = render "shared/milestones/tab_loading" + .tab-pane.active#tab-issues{ data: { sort_endpoint: (sort_issues_project_milestone_path(@project, @milestone) if @project && current_user) } } + = render 'shared/milestones/issues_tab', issues: issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name + .tab-pane#tab-merge-requests + -# loaded async + = render "shared/milestones/tab_loading" .tab-pane#tab-participants -# loaded async = render "shared/milestones/tab_loading" diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index fd3317341f6..12575b30a6c 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -4,54 +4,15 @@ - group = local_assigns[:group] - is_dynamic_milestone = milestone.legacy_group_milestone? || milestone.dashboard_milestone? -.detail-page-header.milestone-page-header - .status-box{ class: "status-box-#{milestone.closed? ? 'closed' : 'open'}" } - - if milestone.closed? - Closed - - elsif milestone.expired? - Expired - - else - Open - - .header-text-content - %span.identifier - Milestone #{milestone.title} - - if milestone.due_date || milestone.start_date - %span.creator - · - = milestone_date_range(milestone) - - .milestone-buttons - - if group - - if can?(current_user, :admin_milestone, group) - - if milestone.group_milestone? - = link_to edit_group_milestone_path(group, milestone), class: "btn btn btn-grouped" do - Edit - - if milestone.active? - = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-grouped btn-close" - - else - = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" - - - unless is_dynamic_milestone - = render 'shared/milestones/delete_button' - - %a.btn.btn-default.btn-grouped.float-right.d-block.d-sm-none.js-sidebar-toggle{ href: "#" } - = icon('angle-double-left') - += render 'shared/milestones/header', milestone: milestone = render 'shared/milestones/deprecation_message' if is_dynamic_milestone - -.detail-page-description.milestone-detail - %h2.title - = markdown_field(milestone, :title) - - if milestone.group_milestone? && milestone.description.present? - %div - .description.md - = markdown_field(milestone, :description) += render 'shared/milestones/description', milestone: milestone - if milestone.complete?(current_user) && milestone.active? .alert.alert-success.prepend-top-default - - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' - %span All issues for this milestone are closed. #{close_msg} + %span + = _('All issues for this milestone are closed.') + = group ? _('You may close the milestone now.') : _('Navigate to the project to close the milestone.') = render_if_exists 'shared/milestones/burndown', milestone: milestone, project: @project @@ -77,10 +38,3 @@ Open %td = milestone.expires_at -- elsif milestone.group_milestone? - %br - View - = link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title) - or - = link_to 'Merge Requests', merge_requests_group_path(@group, milestone_title: milestone.title) - in this milestone diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 441abd57334..2b3e986a841 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -17,14 +17,14 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.btn-xs.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) - %button.btn.dropdown-toggle{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.btn-xs.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } .float-left = icon("bell", class: "js-notification-loading") = notification_title(notification_setting.level) diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index 43a87fd8397..1fef43c0c37 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -1,3 +1,5 @@ +- hide_label = local_assigns.fetch(:hide_label, false) + .modal.fade{ tabindex: "-1", role: "dialog", id: notifications_menu_identifier("modal", notification_setting), "aria-labelledby": "custom-notifications-title" } .modal-dialog .modal-content @@ -11,6 +13,7 @@ .container-fluid = form_for notification_setting, html: { class: "custom-notifications-form" } do |f| = hidden_setting_source_input(notification_setting) + = hidden_field_tag("hide_label", true) if hide_label .row .col-lg-4 %h4.prepend-top-0= _('Notification events') diff --git a/app/views/shared/notifications/_new_button.html.haml b/app/views/shared/notifications/_new_button.html.haml index 3c8cc023848..363053b5e35 100644 --- a/app/views/shared/notifications/_new_button.html.haml +++ b/app/views/shared/notifications/_new_button.html.haml @@ -31,4 +31,4 @@ = render "shared/notifications/notification_dropdown", notification_setting: notification_setting = content_for :scripts_body do - = render "shared/notifications/custom_notifications", notification_setting: notification_setting + = render "shared/notifications/custom_notifications", notification_setting: notification_setting, hide_label: true diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index d70a1631010..59b4facdbe5 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -32,7 +32,7 @@ - explore_groups_button_label = _('Explore groups') - explore_groups_button_link = explore_groups_path -.js-projects-list-holder +.js-projects-list-holder{ data: { qa_selector: 'projects_list' } } - if any_projects?(projects) - load_pipeline_status(projects) if pipeline_status %ul.projects-list{ class: css_classes } diff --git a/app/views/shared/runners/_runner_description.html.haml b/app/views/shared/runners/_runner_description.html.haml index 5935750ca06..a47bbd55325 100644 --- a/app/views/shared/runners/_runner_description.html.haml +++ b/app/views/shared/runners/_runner_description.html.haml @@ -1,6 +1,6 @@ .light.prepend-top-default %p - = _("A 'Runner' is a process which runs a job. You can set up as many Runners as you need.") + = _("You can set up as many Runners as you need to run your jobs.") %br = _('Runners can be placed on separate users, servers, and even on your local machine.') diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 2132fcbccc5..6a5e777706c 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -8,7 +8,6 @@ .btn-group{ role: "group" }< = copy_blob_source_button(blob) = open_raw_blob_button(blob) - - = link_to icon('download'), download_snippet_path(@snippet), target: '_blank', class: "btn btn-sm has-tooltip", title: 'Download', data: { container: 'body' } + = download_raw_snippet_button(@snippet) = render 'projects/blob/content', blob: blob diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml index c7f0511d1de..d2e35511b32 100644 --- a/app/views/shared/snippets/_embed.html.haml +++ b/app/views/shared/snippets/_embed.html.haml @@ -17,7 +17,7 @@ .file-actions.d-none.d-sm-block .btn-group{ role: "group" }< - = embedded_snippet_raw_button + = embedded_raw_snippet_button = embedded_snippet_download_button %article.file-holder.snippet-file-content diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 8d94a87a775..67f177288f0 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -44,7 +44,7 @@ %li %button.js-share-btn.btn.btn-transparent{ type: 'button' } %strong.embed-toggle-list-item= _("Share") - %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed } + %input.js-snippet-url-area.snippet-embed-input.form-control{ type: "text", autocomplete: 'off', value: snippet_embed_tag(@snippet) } .input-group-append = clipboard_button(title: _('Copy'), class: 'js-clipboard-btn snippet-clipboard-btn btn btn-default', target: '.js-snippet-url-area') .clearfix diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 0ef626868a2..5602ea37b5c 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -7,8 +7,9 @@ .title = link_to reliable_snippet_path(snippet) do = snippet.title - - if snippet.file_name - %span.snippet-filename.monospace.d-none.d-sm-inline-block + - if snippet.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') = snippet.file_name %ul.controls diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index b161cc65602..66b5214cfcb 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -45,6 +45,9 @@ - gcp_cluster:cluster_project_configure - gcp_cluster:clusters_applications_wait_for_uninstall_app - gcp_cluster:clusters_applications_uninstall +- gcp_cluster:clusters_cleanup_app +- gcp_cluster:clusters_cleanup_project_namespace +- gcp_cluster:clusters_cleanup_service_account - github_import_advance_stage - github_importer:github_import_import_diff_note @@ -176,3 +179,4 @@ - import_issues_csv - project_daily_statistics - create_evidence +- group_export diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 577c439f4a2..9492cfe217c 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -5,6 +5,7 @@ class AuthorizedProjectsWorker prepend WaitableWorker feature_category :authentication_and_authorization + latency_sensitive_worker! # This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the # visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231 diff --git a/app/workers/build_finished_worker.rb b/app/workers/build_finished_worker.rb index e95b6b38d28..e61f37ddce1 100644 --- a/app/workers/build_finished_worker.rb +++ b/app/workers/build_finished_worker.rb @@ -5,6 +5,8 @@ class BuildFinishedWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_hooks_worker.rb b/app/workers/build_hooks_worker.rb index 15b31acf3e5..fa55769e486 100644 --- a/app/workers/build_hooks_worker.rb +++ b/app/workers/build_hooks_worker.rb @@ -6,6 +6,7 @@ class BuildHooksWorker queue_namespace :pipeline_hooks feature_category :continuous_integration + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_queue_worker.rb b/app/workers/build_queue_worker.rb index 6584fba4c65..6f75f403e6e 100644 --- a/app/workers/build_queue_worker.rb +++ b/app/workers/build_queue_worker.rb @@ -6,6 +6,8 @@ class BuildQueueWorker queue_namespace :pipeline_processing feature_category :continuous_integration + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/build_success_worker.rb b/app/workers/build_success_worker.rb index ac947f3cf38..b7dbd367fee 100644 --- a/app/workers/build_success_worker.rb +++ b/app/workers/build_success_worker.rb @@ -5,6 +5,7 @@ class BuildSuccessWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(build_id) diff --git a/app/workers/chat_notification_worker.rb b/app/workers/chat_notification_worker.rb index 3bc2edad62c..42a23cd472a 100644 --- a/app/workers/chat_notification_worker.rb +++ b/app/workers/chat_notification_worker.rb @@ -4,6 +4,11 @@ class ChatNotificationWorker include ApplicationWorker feature_category :chatops + latency_sensitive_worker! + # TODO: break this into multiple jobs + # as the `responder` uses external dependencies + # See https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34 + # worker_has_external_dependencies! RESCHEDULE_INTERVAL = 2.seconds diff --git a/app/workers/ci/build_schedule_worker.rb b/app/workers/ci/build_schedule_worker.rb index f22ec4c7810..e34f16f46c2 100644 --- a/app/workers/ci/build_schedule_worker.rb +++ b/app/workers/ci/build_schedule_worker.rb @@ -7,6 +7,7 @@ module Ci queue_namespace :pipeline_processing feature_category :continuous_integration + worker_resource_boundary :cpu def perform(build_id) ::Ci::Build.find_by_id(build_id).try do |build| diff --git a/app/workers/cluster_install_app_worker.rb b/app/workers/cluster_install_app_worker.rb index 32e2ea7996c..0e075b295dd 100644 --- a/app/workers/cluster_install_app_worker.rb +++ b/app/workers/cluster_install_app_worker.rb @@ -5,6 +5,8 @@ class ClusterInstallAppWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::InstallService.new(app).execute diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb index 0549e81ed05..3f95a764567 100644 --- a/app/workers/cluster_patch_app_worker.rb +++ b/app/workers/cluster_patch_app_worker.rb @@ -5,6 +5,8 @@ class ClusterPatchAppWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::PatchService.new(app).execute diff --git a/app/workers/cluster_project_configure_worker.rb b/app/workers/cluster_project_configure_worker.rb index ad2437a77e9..614029c2b5c 100644 --- a/app/workers/cluster_project_configure_worker.rb +++ b/app/workers/cluster_project_configure_worker.rb @@ -4,6 +4,8 @@ class ClusterProjectConfigureWorker include ApplicationWorker include ClusterQueue + worker_has_external_dependencies! + def perform(project_id) # Scheduled for removal in https://gitlab.com/gitlab-org/gitlab-foss/issues/59319 end diff --git a/app/workers/cluster_provision_worker.rb b/app/workers/cluster_provision_worker.rb index 59de7903c1c..c34284319dd 100644 --- a/app/workers/cluster_provision_worker.rb +++ b/app/workers/cluster_provision_worker.rb @@ -4,10 +4,16 @@ class ClusterProvisionWorker include ApplicationWorker include ClusterQueue + worker_has_external_dependencies! + def perform(cluster_id) Clusters::Cluster.find_by_id(cluster_id).try do |cluster| cluster.provider.try do |provider| - Clusters::Gcp::ProvisionService.new.execute(provider) if cluster.gcp? + if cluster.gcp? + Clusters::Gcp::ProvisionService.new.execute(provider) + elsif cluster.aws? + Clusters::Aws::ProvisionService.new.execute(provider) + end end end end diff --git a/app/workers/cluster_upgrade_app_worker.rb b/app/workers/cluster_upgrade_app_worker.rb index d1a538859b4..cd06f0a2224 100644 --- a/app/workers/cluster_upgrade_app_worker.rb +++ b/app/workers/cluster_upgrade_app_worker.rb @@ -5,6 +5,8 @@ class ClusterUpgradeAppWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::UpgradeService.new(app).execute diff --git a/app/workers/cluster_wait_for_app_installation_worker.rb b/app/workers/cluster_wait_for_app_installation_worker.rb index e8d7e52f70f..7155dc6f835 100644 --- a/app/workers/cluster_wait_for_app_installation_worker.rb +++ b/app/workers/cluster_wait_for_app_installation_worker.rb @@ -8,6 +8,9 @@ class ClusterWaitForAppInstallationWorker INTERVAL = 10.seconds TIMEOUT = 20.minutes + worker_has_external_dependencies! + worker_resource_boundary :cpu + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::CheckInstallationProgressService.new(app).execute diff --git a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb index 6865384df44..14b1651cc72 100644 --- a/app/workers/cluster_wait_for_ingress_ip_address_worker.rb +++ b/app/workers/cluster_wait_for_ingress_ip_address_worker.rb @@ -5,6 +5,8 @@ class ClusterWaitForIngressIpAddressWorker include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::CheckIngressIpAddressService.new(app).execute diff --git a/app/workers/clusters/applications/uninstall_worker.rb b/app/workers/clusters/applications/uninstall_worker.rb index 85e8ecc4ad5..6180998c8d9 100644 --- a/app/workers/clusters/applications/uninstall_worker.rb +++ b/app/workers/clusters/applications/uninstall_worker.rb @@ -7,6 +7,8 @@ module Clusters include ClusterQueue include ClusterApplications + worker_has_external_dependencies! + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::UninstallService.new(app).execute diff --git a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb index 163c99d3c3c..7907aa8dfff 100644 --- a/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb +++ b/app/workers/clusters/applications/wait_for_uninstall_app_worker.rb @@ -10,6 +10,9 @@ module Clusters INTERVAL = 10.seconds TIMEOUT = 20.minutes + worker_has_external_dependencies! + worker_resource_boundary :cpu + def perform(app_name, app_id) find_application(app_name, app_id) do |app| Clusters::Applications::CheckUninstallProgressService.new(app).execute diff --git a/app/workers/clusters/cleanup/app_worker.rb b/app/workers/clusters/cleanup/app_worker.rb new file mode 100644 index 00000000000..1eedf510ba1 --- /dev/null +++ b/app/workers/clusters/cleanup/app_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class AppWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # We're splitting the above MR in smaller chunks to facilitate reviews + def perform + end + end + end +end diff --git a/app/workers/clusters/cleanup/project_namespace_worker.rb b/app/workers/clusters/cleanup/project_namespace_worker.rb new file mode 100644 index 00000000000..09f2abf5d8a --- /dev/null +++ b/app/workers/clusters/cleanup/project_namespace_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class ProjectNamespaceWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # We're splitting the above MR in smaller chunks to facilitate reviews + def perform + end + end + end +end diff --git a/app/workers/clusters/cleanup/service_account_worker.rb b/app/workers/clusters/cleanup/service_account_worker.rb new file mode 100644 index 00000000000..fab6318a807 --- /dev/null +++ b/app/workers/clusters/cleanup/service_account_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Clusters + module Cleanup + class ServiceAccountWorker + include ApplicationWorker + include ClusterQueue + include ClusterApplications + + # TODO: Merge with https://gitlab.com/gitlab-org/gitlab/merge_requests/16954 + # We're splitting the above MR in smaller chunks to facilitate reviews + def perform + end + end + end +end diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index b856a9329dd..bd0b566658e 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -14,6 +14,7 @@ module Gitlab include NotifyUponDeath feature_category :importers + worker_has_external_dependencies! end # project - An instance of `Project` to import the data into. diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb index 70412ffd095..a75cc643038 100644 --- a/app/workers/create_pipeline_worker.rb +++ b/app/workers/create_pipeline_worker.rb @@ -6,6 +6,8 @@ class CreatePipelineWorker queue_namespace :pipeline_creation feature_category :continuous_integration + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(project_id, user_id, ref, source, params = {}) project = Project.find(project_id) diff --git a/app/workers/deployments/finished_worker.rb b/app/workers/deployments/finished_worker.rb index 79a1caccc92..90bbc193651 100644 --- a/app/workers/deployments/finished_worker.rb +++ b/app/workers/deployments/finished_worker.rb @@ -6,6 +6,7 @@ module Deployments queue_namespace :deployment feature_category :continuous_delivery + worker_resource_boundary :cpu def perform(deployment_id) Deployment.find_by_id(deployment_id).try(:execute_hooks) diff --git a/app/workers/deployments/success_worker.rb b/app/workers/deployments/success_worker.rb index f6520307186..4a29f1aef52 100644 --- a/app/workers/deployments/success_worker.rb +++ b/app/workers/deployments/success_worker.rb @@ -6,6 +6,7 @@ module Deployments queue_namespace :deployment feature_category :continuous_delivery + worker_resource_boundary :cpu def perform(deployment_id) Deployment.find_by_id(deployment_id).try do |deployment| diff --git a/app/workers/email_receiver_worker.rb b/app/workers/email_receiver_worker.rb index c82728be329..b56bf4ed833 100644 --- a/app/workers/email_receiver_worker.rb +++ b/app/workers/email_receiver_worker.rb @@ -4,6 +4,7 @@ class EmailReceiverWorker include ApplicationWorker feature_category :issue_tracking + latency_sensitive_worker! def perform(raw) return unless Gitlab::IncomingEmail.enabled? diff --git a/app/workers/emails_on_push_worker.rb b/app/workers/emails_on_push_worker.rb index 2231c91a720..f523f5953e1 100644 --- a/app/workers/emails_on_push_worker.rb +++ b/app/workers/emails_on_push_worker.rb @@ -6,6 +6,8 @@ class EmailsOnPushWorker attr_reader :email, :skip_premailer feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(project_id, recipients, push_data, options = {}) options.symbolize_keys! diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 9545227fa31..383fd30e098 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -7,25 +7,6 @@ class ExpireBuildArtifactsWorker feature_category :continuous_integration def perform - if Feature.enabled?(:ci_new_expire_job_artifacts_service, default_enabled: true) - perform_efficient_artifacts_removal - else - perform_legacy_artifacts_removal - end - end - - def perform_efficient_artifacts_removal Ci::DestroyExpiredJobArtifactsService.new.execute end - - # rubocop: disable CodeReuse/ActiveRecord - def perform_legacy_artifacts_removal - Rails.logger.info 'Scheduling removal of build artifacts' # rubocop:disable Gitlab/RailsLogger - - build_ids = Ci::Build.with_expired_artifacts.pluck(:id) - build_ids = build_ids.map { |build_id| [build_id] } - - ExpireBuildInstanceArtifactsWorker.bulk_perform_async(build_ids) - end - # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/workers/expire_job_cache_worker.rb b/app/workers/expire_job_cache_worker.rb index b09d0a5d121..0363429587e 100644 --- a/app/workers/expire_job_cache_worker.rb +++ b/app/workers/expire_job_cache_worker.rb @@ -5,6 +5,7 @@ class ExpireJobCacheWorker include PipelineQueue queue_namespace :pipeline_cache + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(job_id) diff --git a/app/workers/expire_pipeline_cache_worker.rb b/app/workers/expire_pipeline_cache_worker.rb index 78e68d7bf46..ab57c59ffda 100644 --- a/app/workers/expire_pipeline_cache_worker.rb +++ b/app/workers/expire_pipeline_cache_worker.rb @@ -5,6 +5,8 @@ class ExpirePipelineCacheWorker include PipelineQueue queue_namespace :pipeline_cache + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/gitlab_shell_worker.rb b/app/workers/gitlab_shell_worker.rb index 9766331cf4b..57e64570c09 100644 --- a/app/workers/gitlab_shell_worker.rb +++ b/app/workers/gitlab_shell_worker.rb @@ -5,8 +5,11 @@ class GitlabShellWorker include Gitlab::ShellAdapter feature_category :source_code_management + latency_sensitive_worker! def perform(action, *arg) - gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend + Gitlab::GitalyClient::NamespaceService.allow do + gitlab_shell.__send__(action, *arg) # rubocop:disable GitlabSecurity/PublicSend + end end end diff --git a/app/workers/group_export_worker.rb b/app/workers/group_export_worker.rb new file mode 100644 index 00000000000..51dbdc95661 --- /dev/null +++ b/app/workers/group_export_worker.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class GroupExportWorker + include ApplicationWorker + include ExceptionBacktrace + + feature_category :source_code_management + + def perform(current_user_id, group_id, params = {}) + current_user = User.find(current_user_id) + group = Group.find(group_id) + + ::Groups::ImportExport::ExportService.new(group: group, user: current_user, params: params).execute + end +end diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb index f00a459a097..8c0ec97638f 100644 --- a/app/workers/hashed_storage/project_migrate_worker.rb +++ b/app/workers/hashed_storage/project_migrate_worker.rb @@ -16,7 +16,7 @@ module HashedStorage project = Project.without_deleted.find_by(id: project_id) break unless project - old_disk_path ||= project.disk_path + old_disk_path ||= Storage::LegacyProject.new(project).disk_path ::Projects::HashedStorage::MigrationService.new(project, old_disk_path, logger: logger).execute end diff --git a/app/workers/import_issues_csv_worker.rb b/app/workers/import_issues_csv_worker.rb index d9834320318..d2733dc5f56 100644 --- a/app/workers/import_issues_csv_worker.rb +++ b/app/workers/import_issues_csv_worker.rb @@ -4,6 +4,7 @@ class ImportIssuesCsvWorker include ApplicationWorker feature_category :issue_tracking + worker_resource_boundary :cpu sidekiq_retries_exhausted do |job| Upload.find(job['args'][2]).destroy diff --git a/app/workers/mail_scheduler/notification_service_worker.rb b/app/workers/mail_scheduler/notification_service_worker.rb index 0d06dab3b2e..4130ce25878 100644 --- a/app/workers/mail_scheduler/notification_service_worker.rb +++ b/app/workers/mail_scheduler/notification_service_worker.rb @@ -8,6 +8,7 @@ module MailScheduler include MailSchedulerQueue feature_category :issue_tracking + worker_resource_boundary :cpu def perform(meth, *args) check_arguments!(args) diff --git a/app/workers/merge_worker.rb b/app/workers/merge_worker.rb index 70b909afea8..ed88c57e8d4 100644 --- a/app/workers/merge_worker.rb +++ b/app/workers/merge_worker.rb @@ -4,6 +4,7 @@ class MergeWorker include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! def perform(merge_request_id, current_user_id, params) params = params.with_indifferent_access diff --git a/app/workers/namespaces/prune_aggregation_schedules_worker.rb b/app/workers/namespaces/prune_aggregation_schedules_worker.rb index 16259ffbfa6..9a5f533fe9a 100644 --- a/app/workers/namespaces/prune_aggregation_schedules_worker.rb +++ b/app/workers/namespaces/prune_aggregation_schedules_worker.rb @@ -6,6 +6,7 @@ module Namespaces include CronjobQueue feature_category :source_code_management + worker_resource_boundary :cpu # Worker to prune pending rows on Namespace::AggregationSchedule # It's scheduled to run once a day at 1:05am. diff --git a/app/workers/new_issue_worker.rb b/app/workers/new_issue_worker.rb index 1b0fec597e7..af9ca332d3c 100644 --- a/app/workers/new_issue_worker.rb +++ b/app/workers/new_issue_worker.rb @@ -5,6 +5,8 @@ class NewIssueWorker include NewIssuable feature_category :issue_tracking + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(issue_id, user_id) return unless objects_found?(issue_id, user_id) diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index 0a5b2f86331..aa3f85c157b 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -5,6 +5,8 @@ class NewMergeRequestWorker include NewIssuable feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(merge_request_id, user_id) return unless objects_found?(merge_request_id, user_id) diff --git a/app/workers/new_note_worker.rb b/app/workers/new_note_worker.rb index d0d2a563738..2a5988a7e32 100644 --- a/app/workers/new_note_worker.rb +++ b/app/workers/new_note_worker.rb @@ -4,6 +4,8 @@ class NewNoteWorker include ApplicationWorker feature_category :issue_tracking + latency_sensitive_worker! + worker_resource_boundary :cpu # Keep extra parameter to preserve backwards compatibility with # old `NewNoteWorker` jobs (can remove later) diff --git a/app/workers/new_release_worker.rb b/app/workers/new_release_worker.rb index 28d2517238e..a3a882f9343 100644 --- a/app/workers/new_release_worker.rb +++ b/app/workers/new_release_worker.rb @@ -7,7 +7,7 @@ class NewReleaseWorker feature_category :release_orchestration def perform(release_id) - release = Release.with_project_and_namespace.find_by_id(release_id) + release = Release.preloaded.find_by_id(release_id) return unless release NotificationService.new.send_new_release_notifications(release) diff --git a/app/workers/object_pool/join_worker.rb b/app/workers/object_pool/join_worker.rb index 9c5161fd55a..ddd002eabb8 100644 --- a/app/workers/object_pool/join_worker.rb +++ b/app/workers/object_pool/join_worker.rb @@ -5,6 +5,8 @@ module ObjectPool include ApplicationWorker include ObjectPoolQueue + worker_resource_boundary :cpu + # The use of pool id is deprecated. Keeping the argument allows old jobs to # still be performed. def perform(_pool_id, project_id) diff --git a/app/workers/pages_domain_removal_cron_worker.rb b/app/workers/pages_domain_removal_cron_worker.rb index 25e747c78d0..b1506831056 100644 --- a/app/workers/pages_domain_removal_cron_worker.rb +++ b/app/workers/pages_domain_removal_cron_worker.rb @@ -5,6 +5,7 @@ class PagesDomainRemovalCronWorker include CronjobQueue feature_category :pages + worker_resource_boundary :cpu def perform PagesDomain.for_removal.find_each do |domain| diff --git a/app/workers/pipeline_hooks_worker.rb b/app/workers/pipeline_hooks_worker.rb index eae1115e60c..04abc9c88fd 100644 --- a/app/workers/pipeline_hooks_worker.rb +++ b/app/workers/pipeline_hooks_worker.rb @@ -5,6 +5,8 @@ class PipelineHooksWorker include PipelineQueue queue_namespace :pipeline_hooks + latency_sensitive_worker! + worker_resource_boundary :cpu # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/pipeline_metrics_worker.rb b/app/workers/pipeline_metrics_worker.rb index 0ddad43b8d5..3830522aaa1 100644 --- a/app/workers/pipeline_metrics_worker.rb +++ b/app/workers/pipeline_metrics_worker.rb @@ -4,6 +4,8 @@ class PipelineMetricsWorker include ApplicationWorker include PipelineQueue + latency_sensitive_worker! + # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| diff --git a/app/workers/pipeline_notification_worker.rb b/app/workers/pipeline_notification_worker.rb index e4a18573d20..62ecbc8a047 100644 --- a/app/workers/pipeline_notification_worker.rb +++ b/app/workers/pipeline_notification_worker.rb @@ -4,6 +4,9 @@ class PipelineNotificationWorker include ApplicationWorker include PipelineQueue + latency_sensitive_worker! + worker_resource_boundary :cpu + # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id, recipients = nil) pipeline = Ci::Pipeline.find_by(id: pipeline_id) diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index 96f3725dbbe..2a36ab992e9 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -6,6 +6,7 @@ class PipelineProcessWorker queue_namespace :pipeline_processing feature_category :continuous_integration + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id, build_ids = nil) diff --git a/app/workers/pipeline_schedule_worker.rb b/app/workers/pipeline_schedule_worker.rb index f500ea08353..19c3c5fcc2f 100644 --- a/app/workers/pipeline_schedule_worker.rb +++ b/app/workers/pipeline_schedule_worker.rb @@ -5,6 +5,7 @@ class PipelineScheduleWorker include CronjobQueue feature_category :continuous_integration + worker_resource_boundary :cpu def perform Ci::PipelineSchedule.runnable_schedules.preloaded.find_in_batches do |schedules| diff --git a/app/workers/pipeline_success_worker.rb b/app/workers/pipeline_success_worker.rb index 666331e6cd4..5c24f00e0c3 100644 --- a/app/workers/pipeline_success_worker.rb +++ b/app/workers/pipeline_success_worker.rb @@ -5,6 +5,7 @@ class PipelineSuccessWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! def perform(pipeline_id) # no-op diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 13a748e1551..5b742461f7a 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -5,6 +5,7 @@ class PipelineUpdateWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(pipeline_id) diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index a3bc7e5b9c9..334a98a0017 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -4,6 +4,8 @@ class PostReceive include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(gl_repository, identifier, changes, push_options = {}) project, repo_type = Gitlab::GlRepository.parse(gl_repository) diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 1e4561fc6ea..8b4d66ae493 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -11,6 +11,7 @@ class ProcessCommitWorker include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! # project_id - The ID of the project this commit belongs to. # user_id - The ID of the user that pushed the commit. diff --git a/app/workers/project_cache_worker.rb b/app/workers/project_cache_worker.rb index 57a01c0dd8e..ae1d57aa124 100644 --- a/app/workers/project_cache_worker.rb +++ b/app/workers/project_cache_worker.rb @@ -3,6 +3,9 @@ # Worker for updating any project specific caches. class ProjectCacheWorker include ApplicationWorker + + latency_sensitive_worker! + LEASE_TIMEOUT = 15.minutes.to_i feature_category :source_code_management diff --git a/app/workers/project_export_worker.rb b/app/workers/project_export_worker.rb index bbcf3b72718..11f3fed82cd 100644 --- a/app/workers/project_export_worker.rb +++ b/app/workers/project_export_worker.rb @@ -6,6 +6,7 @@ class ProjectExportWorker sidekiq_options retry: 3 feature_category :source_code_management + worker_resource_boundary :memory def perform(current_user_id, project_id, after_export_strategy = {}, params = {}) current_user = User.find(current_user_id) diff --git a/app/workers/project_service_worker.rb b/app/workers/project_service_worker.rb index 8041404fc71..38a2a7414a5 100644 --- a/app/workers/project_service_worker.rb +++ b/app/workers/project_service_worker.rb @@ -5,6 +5,7 @@ class ProjectServiceWorker sidekiq_options dead: false feature_category :integrations + worker_has_external_dependencies! def perform(hook_id, data) data = data.with_indifferent_access diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index af4a3def062..f3a83e0e8d4 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -5,6 +5,14 @@ class ReactiveCachingWorker feature_category_not_owned! + # TODO: The reactive caching worker should be split into + # two different workers, one for latency_sensitive jobs without external dependencies + # and another worker without latency_sensitivity, but with external dependencies + # https://gitlab.com/gitlab-com/gl-infra/scalability/issues/34 + # This worker should also have `worker_has_external_dependencies!` enabled + latency_sensitive_worker! + worker_resource_boundary :cpu + def perform(class_name, id, *args) klass = begin class_name.constantize diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index 147b412b772..a43e6fd11d5 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -8,5 +8,9 @@ class RemoveExpiredGroupLinksWorker def perform ProjectGroupLink.expired.destroy_all # rubocop: disable DestroyAll + + GroupGroupLink.expired.find_in_batches do |link_batch| + Groups::GroupLinks::DestroyService.new(nil, nil).execute(link_batch) + end end end diff --git a/app/workers/remove_expired_members_worker.rb b/app/workers/remove_expired_members_worker.rb index 75f06fd9f6b..bf209fcec9f 100644 --- a/app/workers/remove_expired_members_worker.rb +++ b/app/workers/remove_expired_members_worker.rb @@ -5,6 +5,7 @@ class RemoveExpiredMembersWorker include CronjobQueue feature_category :authentication_and_authorization + worker_resource_boundary :cpu def perform Member.expired.find_each do |member| diff --git a/app/workers/repository_import_worker.rb b/app/workers/repository_import_worker.rb index bc2d0366fdd..15677fb0a95 100644 --- a/app/workers/repository_import_worker.rb +++ b/app/workers/repository_import_worker.rb @@ -7,6 +7,7 @@ class RepositoryImportWorker include ProjectImportOptions feature_category :importers + worker_has_external_dependencies! # technical debt: https://gitlab.com/gitlab-org/gitlab/issues/33991 sidekiq_options memory_killer_memory_growth_kb: ENV.fetch('MEMORY_KILLER_REPOSITORY_IMPORT_WORKER_MEMORY_GROWTH_KB', 50).to_i diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb index b4d96546fa4..d1dec4cb732 100644 --- a/app/workers/repository_update_remote_mirror_worker.rb +++ b/app/workers/repository_update_remote_mirror_worker.rb @@ -6,6 +6,8 @@ class RepositoryUpdateRemoteMirrorWorker include ApplicationWorker include Gitlab::ExclusiveLeaseHelpers + worker_has_external_dependencies! + sidekiq_options retry: 3, dead: false feature_category :source_code_management diff --git a/app/workers/stage_update_worker.rb b/app/workers/stage_update_worker.rb index ea587789d03..de2454128f6 100644 --- a/app/workers/stage_update_worker.rb +++ b/app/workers/stage_update_worker.rb @@ -5,6 +5,7 @@ class StageUpdateWorker include PipelineQueue queue_namespace :pipeline_processing + latency_sensitive_worker! # rubocop: disable CodeReuse/ActiveRecord def perform(stage_id) diff --git a/app/workers/stuck_ci_jobs_worker.rb b/app/workers/stuck_ci_jobs_worker.rb index 971edb1f14f..b116965d105 100644 --- a/app/workers/stuck_ci_jobs_worker.rb +++ b/app/workers/stuck_ci_jobs_worker.rb @@ -5,6 +5,7 @@ class StuckCiJobsWorker include CronjobQueue feature_category :continuous_integration + worker_resource_boundary :cpu EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease' @@ -72,5 +73,19 @@ class StuckCiJobsWorker Gitlab::OptimisticLocking.retry_lock(build, 3) do |b| b.drop(reason) end + rescue => ex + build.doom! + + track_exception_for_build(ex, build) + end + + def track_exception_for_build(ex, build) + Gitlab::Sentry.track_acceptable_exception(ex, extra: { + build_id: build.id, + build_name: build.name, + build_stage: build.stage, + pipeline_id: build.pipeline_id, + project_id: build.project_id + }) end end diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index 4993cd1220c..d9a9a613ca9 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -5,6 +5,7 @@ class StuckImportJobsWorker include CronjobQueue feature_category :importers + worker_resource_boundary :cpu IMPORT_JOBS_EXPIRATION = 15.hours.to_i diff --git a/app/workers/update_head_pipeline_for_merge_request_worker.rb b/app/workers/update_head_pipeline_for_merge_request_worker.rb index 77859abfea4..e069b16eb90 100644 --- a/app/workers/update_head_pipeline_for_merge_request_worker.rb +++ b/app/workers/update_head_pipeline_for_merge_request_worker.rb @@ -6,6 +6,8 @@ class UpdateHeadPipelineForMergeRequestWorker queue_namespace :pipeline_processing feature_category :continuous_integration + latency_sensitive_worker! + worker_resource_boundary :cpu def perform(merge_request_id) MergeRequest.find_by_id(merge_request_id).try do |merge_request| diff --git a/app/workers/update_merge_requests_worker.rb b/app/workers/update_merge_requests_worker.rb index 8e1703cdd0b..acb95353983 100644 --- a/app/workers/update_merge_requests_worker.rb +++ b/app/workers/update_merge_requests_worker.rb @@ -4,6 +4,8 @@ class UpdateMergeRequestsWorker include ApplicationWorker feature_category :source_code_management + latency_sensitive_worker! + worker_resource_boundary :cpu LOG_TIME_THRESHOLD = 90 # seconds diff --git a/app/workers/wait_for_cluster_creation_worker.rb b/app/workers/wait_for_cluster_creation_worker.rb index 8aa1d9290fd..621125c8503 100644 --- a/app/workers/wait_for_cluster_creation_worker.rb +++ b/app/workers/wait_for_cluster_creation_worker.rb @@ -4,10 +4,16 @@ class WaitForClusterCreationWorker include ApplicationWorker include ClusterQueue + worker_has_external_dependencies! + def perform(cluster_id) Clusters::Cluster.find_by_id(cluster_id).try do |cluster| cluster.provider.try do |provider| - Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider) if cluster.gcp? + if cluster.gcp? + Clusters::Gcp::VerifyProvisionStatusService.new.execute(provider) + elsif cluster.aws? + Clusters::Aws::VerifyProvisionStatusService.new.execute(provider) + end end end end diff --git a/app/workers/web_hook_worker.rb b/app/workers/web_hook_worker.rb index fd7ca93683e..c3fa3162c14 100644 --- a/app/workers/web_hook_worker.rb +++ b/app/workers/web_hook_worker.rb @@ -4,6 +4,8 @@ class WebHookWorker include ApplicationWorker feature_category :integrations + worker_has_external_dependencies! + sidekiq_options retry: 4, dead: false def perform(hook_id, data, hook_name) |