diff options
Diffstat (limited to 'app')
54 files changed, 1337 insertions, 350 deletions
diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue index 916fa0d292d..8f3c66b0cbe 100644 --- a/app/assets/javascripts/jobs/components/sidebar_details_block.vue +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -48,11 +48,10 @@ export default { return `${this.job.runner.description} (#${this.job.runner.id})`; }, retryButtonClass() { - let className = 'js-retry-button float-right btn btn-retry d-none d-md-block d-lg-block d-xl-block'; + let className = + 'js-retry-button float-right btn btn-retry d-none d-md-block d-lg-block d-xl-block'; className += - this.job.status && this.job.recoverable - ? ' btn-primary' - : ' btn-inverted-secondary'; + this.job.status && this.job.recoverable ? ' btn-primary' : ' btn-inverted-secondary'; return className; }, hasTimeout() { @@ -104,8 +103,7 @@ export default { <button type="button" :aria-label="__('Toggle Sidebar')" - class="btn btn-blank gutter-toggle pull-right - d-block d-sm-block d-md-none js-sidebar-build-toggle" + class="btn btn-blank gutter-toggle float-right d-block d-md-none js-sidebar-build-toggle" > <i aria-hidden="true" diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index bb91ac84ffb..8737f537296 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,9 +1,15 @@ import groupAvatar from '~/group_avatar'; import TransferDropdown from '~/groups/transfer_dropdown'; import initConfirmDangerModal from '~/confirm_danger_modal'; +import initSettingsPanels from '~/settings_panels'; document.addEventListener('DOMContentLoaded', () => { groupAvatar(); new TransferDropdown(); // eslint-disable-line no-new initConfirmDangerModal(); }); + +document.addEventListener('DOMContentLoaded', () => { + // Initialize expandable settings panels + initSettingsPanels(); +}); diff --git a/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js b/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js new file mode 100644 index 00000000000..d4f34e32a48 --- /dev/null +++ b/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js @@ -0,0 +1,5 @@ +import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; + +document.addEventListener('DOMContentLoaded', () => { + initGkeDropdowns(); +}); diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index 34a12ef76a1..dcd0b9a76ce 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -1,5 +1,7 @@ import bp from '../../../breakpoints'; import { slugify } from '../../../lib/utils/text_utility'; +import { parseQueryStringIntoObject } from '../../../lib/utils/common_utils'; +import { mergeUrlParams, redirectTo } from '../../../lib/utils/url_utility'; export default class Wikis { constructor() { @@ -28,7 +30,12 @@ export default class Wikis { if (slug.length > 0) { const wikisPath = slugInput.getAttribute('data-wikis-path'); - window.location.href = `${wikisPath}/${slug}`; + + // If the wiki is empty, we need to merge the current URL params to keep the "create" view. + const params = parseQueryStringIntoObject(window.location.search.substr(1)); + const url = mergeUrlParams(params, `${wikisPath}/${slug}`); + redirectTo(url); + e.preventDefault(); } } diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js new file mode 100644 index 00000000000..c15d8ba49e1 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js @@ -0,0 +1,71 @@ +import _ from 'underscore'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +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 store from '../store'; + +export default { + store, + components: { + LoadingIcon, + DropdownButton, + DropdownSearchInput, + DropdownHiddenInput, + }, + props: { + fieldId: { + type: String, + required: true, + }, + fieldName: { + type: String, + required: true, + }, + defaultValue: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isLoading: false, + hasErrors: false, + searchQuery: '', + gapiError: '', + }; + }, + computed: { + results() { + if (!this.items) { + return []; + } + + return this.items.filter(item => item.name.toLowerCase().indexOf(this.searchQuery) > -1); + }, + }, + methods: { + fetchSuccessHandler() { + if (this.defaultValue) { + const itemToSelect = _.find(this.items, item => item.name === this.defaultValue); + + if (itemToSelect) { + this.setItem(itemToSelect.name); + } + } + + this.isLoading = false; + this.hasErrors = false; + }, + fetchFailureHandler(resp) { + this.isLoading = false; + this.hasErrors = true; + + if (resp.result && resp.result.error) { + this.gapiError = resp.result.error.message; + } + }, + }, +}; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue new file mode 100644 index 00000000000..6be39702546 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue @@ -0,0 +1,142 @@ +<script> +import { sprintf, s__ } from '~/locale'; +import { mapState, mapGetters, mapActions } from 'vuex'; + +import gkeDropdownMixin from './gke_dropdown_mixin'; + +export default { + name: 'GkeMachineTypeDropdown', + mixins: [gkeDropdownMixin], + computed: { + ...mapState([ + 'isValidatingProjectBilling', + 'projectHasBillingEnabled', + 'selectedZone', + 'selectedMachineType', + ]), + ...mapState({ items: 'machineTypes' }), + ...mapGetters(['hasZone', 'hasMachineType']), + allDropdownsSelected() { + return this.projectHasBillingEnabled && this.hasZone && this.hasMachineType; + }, + isDisabled() { + return ( + this.isLoading || + this.isValidatingProjectBilling || + !this.projectHasBillingEnabled || + !this.hasZone + ); + }, + toggleText() { + if (this.isLoading) { + return s__('ClusterIntegration|Fetching machine types'); + } + + if (this.selectedMachineType) { + return this.selectedMachineType; + } + + if (!this.projectHasBillingEnabled && !this.hasZone) { + return s__('ClusterIntegration|Select project and zone to choose machine type'); + } + + return !this.hasZone + ? s__('ClusterIntegration|Select zone to choose machine type') + : s__('ClusterIntegration|Select machine type'); + }, + errorMessage() { + return sprintf( + s__( + 'ClusterIntegration|An error occured while trying to fetch zone machine types: %{error}', + ), + { error: this.gapiError }, + ); + }, + }, + watch: { + selectedZone() { + this.hasErrors = false; + + if (this.hasZone) { + this.isLoading = true; + + this.fetchMachineTypes() + .then(this.fetchSuccessHandler) + .catch(this.fetchFailureHandler); + } + }, + selectedMachineType() { + this.enableSubmit(); + }, + }, + methods: { + ...mapActions(['fetchMachineTypes']), + ...mapActions({ setItem: 'setMachineType' }), + enableSubmit() { + if (this.allDropdownsSelected) { + const submitButtonEl = document.querySelector('.js-gke-cluster-creation-submit'); + + if (submitButtonEl) { + submitButtonEl.removeAttribute('disabled'); + } + } + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-gcp-machine-type-dropdown dropdown" + :class="{ 'gl-show-field-errors': hasErrors }" + > + <dropdown-hidden-input + :name="fieldName" + :value="selectedMachineType" + /> + <dropdown-button + :class="{ 'gl-field-error-outline': hasErrors }" + :is-disabled="isDisabled" + :is-loading="isLoading" + :toggle-text="toggleText" + /> + <div class="dropdown-menu dropdown-select"> + <dropdown-search-input + v-model="searchQuery" + :placeholder-text="s__('ClusterIntegration|Search machine types')" + /> + <div class="dropdown-content"> + <ul> + <li v-show="!results.length"> + <span class="menu-item"> + {{ s__('ClusterIntegration|No machine types matched your search') }} + </span> + </li> + <li + v-for="result in results" + :key="result.id" + > + <button + type="button" + @click.prevent="setItem(result.name)" + > + {{ result.name }} + </button> + </li> + </ul> + </div> + <div class="dropdown-loading"> + <loading-icon /> + </div> + </div> + </div> + <span + class="help-block" + :class="{ 'gl-field-error': hasErrors }" + v-if="hasErrors" + > + {{ errorMessage }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue new file mode 100644 index 00000000000..f813a4625d6 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue @@ -0,0 +1,201 @@ +<script> +import _ from 'underscore'; +import { s__, sprintf } from '~/locale'; +import { mapState, mapGetters, mapActions } from 'vuex'; + +import gkeDropdownMixin from './gke_dropdown_mixin'; + +export default { + name: 'GkeProjectIdDropdown', + mixins: [gkeDropdownMixin], + props: { + docsUrl: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['selectedProject', 'isValidatingProjectBilling', 'projectHasBillingEnabled']), + ...mapState({ items: 'projects' }), + ...mapGetters(['hasProject']), + hasOneProject() { + return this.items && this.items.length === 1; + }, + isDisabled() { + return ( + this.isLoading || this.isValidatingProjectBilling || (this.items && this.items.length < 2) + ); + }, + toggleText() { + if (this.isValidatingProjectBilling) { + return s__('ClusterIntegration|Validating project billing status'); + } + + if (this.isLoading) { + return s__('ClusterIntegration|Fetching projects'); + } + + if (this.hasProject) { + return this.selectedProject.name; + } + + if (!this.items) { + return s__('ClusterIntegration|No projects found'); + } + + return s__('ClusterIntegration|Select project'); + }, + helpText() { + let message; + if (this.hasErrors) { + return this.errorMessage; + } + + if (!this.items) { + message = + 'ClusterIntegration|We were unable to fetch any projects. Ensure that you have a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.'; + } + + message = + this.items && this.items.length + ? 'ClusterIntegration|To use a new project, first create one on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.' + : 'ClusterIntegration|To create a cluster, first create a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.'; + + return sprintf( + s__(message), + { + docsLinkEnd: ' <i class="fa fa-external-link" aria-hidden="true"></i></a>', + docsLinkStart: `<a href="${_.escape( + this.docsUrl, + )}" target="_blank" rel="noopener noreferrer">`, + }, + false, + ); + }, + errorMessage() { + if (!this.projectHasBillingEnabled) { + if (this.gapiError) { + return s__( + 'ClusterIntegration|We could not verify that one of your projects on GCP has billing enabled. Please try again.', + ); + } + + return sprintf( + s__( + 'This project does not have billing enabled. To create a cluster, <a href=%{linkToBilling} target="_blank" rel="noopener noreferrer">enable billing <i class="fa fa-external-link" aria-hidden="true"></i></a> and try again.', + ), + { + linkToBilling: + 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', + }, + false, + ); + } + + return sprintf( + s__('ClusterIntegration|An error occured while trying to fetch your projects: %{error}'), + { error: this.gapiError }, + ); + }, + }, + watch: { + selectedProject() { + this.setIsValidatingProjectBilling(true); + + this.validateProjectBilling() + .then(this.validateProjectBillingSuccessHandler) + .catch(this.validateProjectBillingFailureHandler); + }, + }, + created() { + this.isLoading = true; + + this.fetchProjects() + .then(this.fetchSuccessHandler) + .catch(this.fetchFailureHandler); + }, + methods: { + ...mapActions(['fetchProjects', 'setIsValidatingProjectBilling', 'validateProjectBilling']), + ...mapActions({ setItem: 'setProject' }), + fetchSuccessHandler() { + if (this.defaultValue) { + const projectToSelect = _.find(this.items, item => item.projectId === this.defaultValue); + + if (projectToSelect) { + this.setItem(projectToSelect); + } + } else if (this.items.length === 1) { + this.setItem(this.items[0]); + } + + this.isLoading = false; + this.hasErrors = false; + }, + validateProjectBillingSuccessHandler() { + this.hasErrors = !this.projectHasBillingEnabled; + }, + validateProjectBillingFailureHandler(resp) { + this.hasErrors = true; + + this.gapiError = resp.result ? resp.result.error.message : resp; + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-gcp-project-id-dropdown dropdown" + :class="{ 'gl-show-field-errors': hasErrors }" + > + <dropdown-hidden-input + :name="fieldName" + :value="selectedProject.projectId" + /> + <dropdown-button + :class="{ + 'gl-field-error-outline': hasErrors, + 'read-only': hasOneProject + }" + :is-disabled="isDisabled" + :is-loading="isLoading" + :toggle-text="toggleText" + /> + <div class="dropdown-menu dropdown-select"> + <dropdown-search-input + v-model="searchQuery" + :placeholder-text="s__('ClusterIntegration|Search projects')" + /> + <div class="dropdown-content"> + <ul> + <li v-show="!results.length"> + <span class="menu-item"> + {{ s__('ClusterIntegration|No projects matched your search') }} + </span> + </li> + <li + v-for="result in results" + :key="result.project_number" + > + <button + type="button" + @click.prevent="setItem(result)" + > + {{ result.name }} + </button> + </li> + </ul> + </div> + <div class="dropdown-loading"> + <loading-icon /> + </div> + </div> + </div> + <span + class="help-block" + :class="{ 'gl-field-error': hasErrors }" + v-html="helpText" + ></span> + </div> +</template> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue new file mode 100644 index 00000000000..0c63ff5dc63 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue @@ -0,0 +1,116 @@ +<script> +import { sprintf, s__ } from '~/locale'; +import { mapState, mapActions } from 'vuex'; + +import gkeDropdownMixin from './gke_dropdown_mixin'; + +export default { + name: 'GkeZoneDropdown', + mixins: [gkeDropdownMixin], + computed: { + ...mapState([ + 'selectedProject', + 'selectedZone', + 'projects', + 'isValidatingProjectBilling', + 'projectHasBillingEnabled', + ]), + ...mapState({ items: 'zones' }), + isDisabled() { + return this.isLoading || this.isValidatingProjectBilling || !this.projectHasBillingEnabled; + }, + toggleText() { + if (this.isLoading) { + return s__('ClusterIntegration|Fetching zones'); + } + + if (this.selectedZone) { + return this.selectedZone; + } + + return !this.projectHasBillingEnabled + ? s__('ClusterIntegration|Select project to choose zone') + : s__('ClusterIntegration|Select zone'); + }, + errorMessage() { + return sprintf( + s__('ClusterIntegration|An error occured while trying to fetch project zones: %{error}'), + { error: this.gapiError }, + ); + }, + }, + watch: { + isValidatingProjectBilling(isValidating) { + this.hasErrors = false; + + if (!isValidating && this.projectHasBillingEnabled) { + this.isLoading = true; + + this.fetchZones() + .then(this.fetchSuccessHandler) + .catch(this.fetchFailureHandler); + } + }, + }, + methods: { + ...mapActions(['fetchZones']), + ...mapActions({ setItem: 'setZone' }), + }, +}; +</script> + +<template> + <div> + <div + class="js-gcp-zone-dropdown dropdown" + :class="{ 'gl-show-field-errors': hasErrors }" + > + <dropdown-hidden-input + :name="fieldName" + :value="selectedZone" + /> + <dropdown-button + :class="{ 'gl-field-error-outline': hasErrors }" + :is-disabled="isDisabled" + :is-loading="isLoading" + :toggle-text="toggleText" + /> + <div class="dropdown-menu dropdown-select"> + <dropdown-search-input + v-model="searchQuery" + :placeholder-text="s__('ClusterIntegration|Search zones')" + /> + <div class="dropdown-content"> + <ul> + <li v-show="!results.length"> + <span class="menu-item"> + {{ s__('ClusterIntegration|No zones matched your search') }} + </span> + </li> + <li + v-for="result in results" + :key="result.id" + > + <button + type="button" + @click.prevent="setItem(result.name)" + > + {{ result.name }} + </button> + </li> + </ul> + </div> + <div class="dropdown-loading"> + <loading-icon /> + </div> + </div> + </div> + <span + class="help-block" + :class="{ 'gl-field-error': hasErrors }" + v-if="hasErrors" + > + {{ errorMessage }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js new file mode 100644 index 00000000000..2a1c0819916 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const GCP_API_ERROR = s__( + 'ClusterIntegration|An error occurred when trying to contact the Google Cloud API. Please try again later.', +); +export const GCP_API_CLOUD_BILLING_ENDPOINT = + 'https://www.googleapis.com/discovery/v1/apis/cloudbilling/v1/rest'; +export const GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT = + 'https://www.googleapis.com/discovery/v1/apis/cloudresourcemanager/v1/rest'; +export const GCP_API_COMPUTE_ENDPOINT = + 'https://www.googleapis.com/discovery/v1/apis/compute/v1/rest'; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js new file mode 100644 index 00000000000..729b9404b64 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js @@ -0,0 +1,88 @@ +/* global gapi */ +import Vue from 'vue'; +import Flash from '~/flash'; +import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue'; +import GkeZoneDropdown from './components/gke_zone_dropdown.vue'; +import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue'; +import * as CONSTANTS from './constants'; + +const mountComponent = (entryPoint, component, componentName, extraProps = {}) => { + const el = document.querySelector(entryPoint); + if (!el) return false; + + const hiddenInput = el.querySelector('input'); + + return new Vue({ + el, + components: { + [componentName]: component, + }, + render: createElement => + createElement(componentName, { + props: { + fieldName: hiddenInput.getAttribute('name'), + fieldId: hiddenInput.getAttribute('id'), + defaultValue: hiddenInput.value, + ...extraProps, + }, + }), + }); +}; + +const mountGkeProjectIdDropdown = () => { + const entryPoint = '.js-gcp-project-id-dropdown-entry-point'; + const el = document.querySelector(entryPoint); + + mountComponent(entryPoint, GkeProjectIdDropdown, 'gke-project-id-dropdown', { + docsUrl: el.dataset.docsurl, + }); +}; + +const mountGkeZoneDropdown = () => { + mountComponent('.js-gcp-zone-dropdown-entry-point', GkeZoneDropdown, 'gke-zone-dropdown'); +}; + +const mountGkeMachineTypeDropdown = () => { + mountComponent( + '.js-gcp-machine-type-dropdown-entry-point', + GkeMachineTypeDropdown, + 'gke-machine-type-dropdown', + ); +}; + +const gkeDropdownErrorHandler = () => { + Flash(CONSTANTS.GCP_API_ERROR); +}; + +const initializeGapiClient = () => { + const el = document.querySelector('.js-gke-cluster-creation'); + if (!el) return false; + + return gapi.client + .init({ + discoveryDocs: [ + CONSTANTS.GCP_API_CLOUD_BILLING_ENDPOINT, + CONSTANTS.GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT, + CONSTANTS.GCP_API_COMPUTE_ENDPOINT, + ], + }) + .then(() => { + gapi.client.setToken({ access_token: el.dataset.token }); + + mountGkeProjectIdDropdown(); + mountGkeZoneDropdown(); + mountGkeMachineTypeDropdown(); + }) + .catch(gkeDropdownErrorHandler); +}; + +const initGkeDropdowns = () => { + if (!gapi) { + gkeDropdownErrorHandler(); + return false; + } + + return gapi.load('client', initializeGapiClient); +}; + +export default initGkeDropdowns; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js new file mode 100644 index 00000000000..4834a856271 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js @@ -0,0 +1,95 @@ +/* global gapi */ +import * as types from './mutation_types'; + +const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) => + new Promise((resolve, reject) => { + const request = resource.list(params); + + return request.then( + resp => { + const { result } = resp; + + commit(mutation, result[payloadKey]); + + resolve(); + }, + resp => { + reject(resp); + }, + ); + }); + +export const setProject = ({ commit }, selectedProject) => { + commit(types.SET_PROJECT, selectedProject); +}; + +export const setZone = ({ commit }, selectedZone) => { + commit(types.SET_ZONE, selectedZone); +}; + +export const setMachineType = ({ commit }, selectedMachineType) => { + commit(types.SET_MACHINE_TYPE, selectedMachineType); +}; + +export const setIsValidatingProjectBilling = ({ commit }, isValidatingProjectBilling) => { + commit(types.SET_IS_VALIDATING_PROJECT_BILLING, isValidatingProjectBilling); +}; + +export const fetchProjects = ({ commit }) => + gapiResourceListRequest({ + resource: gapi.client.cloudresourcemanager.projects, + params: {}, + commit, + mutation: types.SET_PROJECTS, + payloadKey: 'projects', + }); + +export const validateProjectBilling = ({ dispatch, commit, state }) => + new Promise((resolve, reject) => { + const request = gapi.client.cloudbilling.projects.getBillingInfo({ + name: `projects/${state.selectedProject.projectId}`, + }); + + commit(types.SET_ZONE, ''); + commit(types.SET_MACHINE_TYPE, ''); + + return request.then( + resp => { + const { billingEnabled } = resp.result; + + commit(types.SET_PROJECT_BILLING_STATUS, !!billingEnabled); + dispatch('setIsValidatingProjectBilling', false); + resolve(); + }, + resp => { + dispatch('setIsValidatingProjectBilling', false); + reject(resp); + }, + ); + }); + +export const fetchZones = ({ commit, state }) => + gapiResourceListRequest({ + resource: gapi.client.compute.zones, + params: { + project: state.selectedProject.projectId, + }, + commit, + mutation: types.SET_ZONES, + payloadKey: 'items', + }); + +export const fetchMachineTypes = ({ commit, state }) => + gapiResourceListRequest({ + resource: gapi.client.compute.machineTypes, + params: { + project: state.selectedProject.projectId, + zone: state.selectedZone, + }, + commit, + mutation: types.SET_MACHINE_TYPES, + payloadKey: 'items', + }); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js new file mode 100644 index 00000000000..e39f02d0894 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js @@ -0,0 +1,3 @@ +export const hasProject = state => !!state.selectedProject.projectId; +export const hasZone = state => !!state.selectedZone; +export const hasMachineType = state => !!state.selectedMachineType; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js new file mode 100644 index 00000000000..5f72060633e --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js new file mode 100644 index 00000000000..45a91efc2d9 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js @@ -0,0 +1,8 @@ +export const SET_PROJECT = 'SET_PROJECT'; +export const SET_PROJECT_BILLING_STATUS = 'SET_PROJECT_BILLING_STATUS'; +export const SET_IS_VALIDATING_PROJECT_BILLING = 'SET_IS_VALIDATING_PROJECT_BILLING'; +export const SET_ZONE = 'SET_ZONE'; +export const SET_MACHINE_TYPE = 'SET_MACHINE_TYPE'; +export const SET_PROJECTS = 'SET_PROJECTS'; +export const SET_ZONES = 'SET_ZONES'; +export const SET_MACHINE_TYPES = 'SET_MACHINE_TYPES'; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js new file mode 100644 index 00000000000..88a2c1b630d --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js @@ -0,0 +1,28 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_PROJECT](state, selectedProject) { + Object.assign(state, { selectedProject }); + }, + [types.SET_IS_VALIDATING_PROJECT_BILLING](state, isValidatingProjectBilling) { + Object.assign(state, { isValidatingProjectBilling }); + }, + [types.SET_PROJECT_BILLING_STATUS](state, projectHasBillingEnabled) { + Object.assign(state, { projectHasBillingEnabled }); + }, + [types.SET_ZONE](state, selectedZone) { + Object.assign(state, { selectedZone }); + }, + [types.SET_MACHINE_TYPE](state, selectedMachineType) { + Object.assign(state, { selectedMachineType }); + }, + [types.SET_PROJECTS](state, projects) { + Object.assign(state, { projects }); + }, + [types.SET_ZONES](state, zones) { + Object.assign(state, { zones }); + }, + [types.SET_MACHINE_TYPES](state, machineTypes) { + Object.assign(state, { machineTypes }); + }, +}; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js new file mode 100644 index 00000000000..9f3c473d4bc --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + selectedProject: { + projectId: '', + name: '', + }, + selectedZone: '', + selectedMachineType: '', + isValidatingProjectBilling: null, + projectHasBillingEnabled: null, + projects: [], + zones: [], + machineTypes: [], +}); diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue new file mode 100644 index 00000000000..c159333d89a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -0,0 +1,55 @@ +<script> +import { __ } from '~/locale'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; + +export default { + components: { + LoadingIcon, + }, + props: { + isDisabled: { + type: Boolean, + required: false, + default: false, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + toggleText: { + type: String, + required: false, + default: __('Select'), + }, + }, +}; +</script> + +<template> + <button + class="dropdown-menu-toggle dropdown-menu-full-width" + type="button" + data-toggle="dropdown" + aria-expanded="false" + :disabled="isDisabled || isLoading" + > + <loading-icon + v-show="isLoading" + :inline="true" + /> + <span class="dropdown-toggle-text"> + {{ toggleText }} + </span> + <span + class="dropdown-toggle-icon" + v-show="!isLoading" + > + <i + class="fa fa-chevron-down" + aria-hidden="true" + data-hidden="true" + ></i> + </span> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue index 1832c3c1757..1fe27eb97ab 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue @@ -5,8 +5,8 @@ export default { type: String, required: true, }, - label: { - type: Object, + value: { + type: [Number, String], required: true, }, }, @@ -17,6 +17,6 @@ export default { <input type="hidden" :name="name" - :value="label.id" + :value="value" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue new file mode 100644 index 00000000000..c2145a26e64 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -0,0 +1,46 @@ +<script> +import { __ } from '~/locale'; + +export default { + props: { + placeholderText: { + type: String, + required: true, + default: __('Search'), + }, + }, + data() { + return { searchQuery: this.value }; + }, + watch: { + searchQuery(query) { + this.$emit('input', query); + }, + }, +}; +</script> + +<template> + <div class="dropdown-input"> + <input + class="dropdown-input-field" + type="search" + v-model="searchQuery" + :placeholder="placeholderText" + autocomplete="off" + /> + <i + class="fa fa-search dropdown-input-search" + aria-hidden="true" + data-hidden="true" + > + </i> + <i + class="fa fa-times dropdown-input-clear js-dropdown-input-clear" + aria-hidden="true" + data-hidden="true" + role="button" + > + </i> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 70b46a9c2bb..f155ac2be02 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -2,13 +2,13 @@ import $ from 'jquery'; import { __ } from '~/locale'; import LabelsSelect from '~/labels_select'; +import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import LoadingIcon from '../../loading_icon.vue'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import DropdownButton from './dropdown_button.vue'; -import DropdownHiddenInput from './dropdown_hidden_input.vue'; import DropdownHeader from './dropdown_header.vue'; import DropdownSearchInput from './dropdown_search_input.vue'; import DropdownFooter from './dropdown_footer.vue'; @@ -140,7 +140,7 @@ export default { v-for="label in context.labels" :key="label.id" :name="hiddenInputName" - :label="label" + :value="label.id" /> <div class="dropdown" diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 1570b1f2eaa..b91d579cae6 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -63,6 +63,10 @@ border-radius: $border-radius-base; white-space: nowrap; + &:disabled.read-only { + color: $gl-text-color !important; + } + &.no-outline { outline: 0; } diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 0188a55cbf3..f85f66b9c0b 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -1,3 +1,30 @@ +@mixin flat-connector-before($length: 44px) { + &::before { + content: ''; + position: absolute; + top: 48%; + left: -$length; + border-top: 2px solid $border-color; + width: $length; + height: 1px; + } +} + +@mixin build-content($border-radius: 30px) { + display: inline-block; + padding: 8px 10px 9px; + width: 100%; + border: 1px solid $border-color; + border-radius: $border-radius; + background-color: $white-light; + + &:hover { + background-color: $stage-hover-bg; + border: 1px solid $dropdown-toggle-active-border-color; + color: $gl-text-color; + } +} + .pipelines { .stage { max-width: 90px; @@ -357,14 +384,8 @@ &:not(:first-child) { margin-left: 44px; - .left-connector::before { - content: ''; - position: absolute; - top: 48%; - left: -44px; - border-top: 2px solid $border-color; - width: 44px; - height: 1px; + .left-connector { + @include flat-connector-before; } } } @@ -479,12 +500,7 @@ } .build-content { - display: inline-block; - padding: 8px 10px 9px; - width: 100%; - border: 1px solid $border-color; - border-radius: 30px; - background-color: $white-light; + @include build-content(); } a.build-content:hover, @@ -622,8 +638,7 @@ } } -// Dropdown button in mini pipeline graph -button.mini-pipeline-graph-dropdown-toggle { +@mixin mini-pipeline-item() { border-radius: 100px; background-color: $white-light; border-width: 1px; @@ -636,30 +651,6 @@ button.mini-pipeline-graph-dropdown-toggle { position: relative; vertical-align: middle; - > .fa.fa-caret-down { - position: absolute; - left: 20px; - top: 5px; - display: inline-block; - visibility: hidden; - opacity: 0; - color: inherit; - font-size: 12px; - transition: visibility 0.1s, opacity 0.1s linear; - } - - &:active, - &:focus, - &:hover { - outline: none; - width: 35px; - - .fa.fa-caret-down { - visibility: visible; - opacity: 1; - } - } - // Dropdown button animation in mini pipeline graph &.ci-status-icon-success { @include mini-pipeline-graph-color($green-100, $green-500, $green-600); @@ -691,6 +682,35 @@ button.mini-pipeline-graph-dropdown-toggle { } } +// Dropdown button in mini pipeline graph +button.mini-pipeline-graph-dropdown-toggle { + @include mini-pipeline-item(); + + > .fa.fa-caret-down { + position: absolute; + left: 20px; + top: 5px; + display: inline-block; + visibility: hidden; + opacity: 0; + color: inherit; + font-size: 12px; + transition: visibility 0.1s, opacity 0.1s linear; + } + + &:active, + &:focus, + &:hover { + outline: none; + width: 35px; + + .fa.fa-caret-down { + visibility: visible; + opacity: 1; + } + } +} + /** Action icons inside dropdowns: - mini graph in pipelines table @@ -744,7 +764,7 @@ button.mini-pipeline-graph-dropdown-toggle { } } - // SVGs in the commit widget and mr widget + // SVGs in the commit widget and mr widget a.ci-action-icon-container.ci-action-icon-wrapper svg { top: 2px; } diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index d6a6bc7d4a1..737942f3eb2 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -1,7 +1,11 @@ class Admin::DashboardController < Admin::ApplicationController include CountHelper + COUNTED_ITEMS = [Project, User, Group, ForkedProjectLink, Issue, MergeRequest, + Note, Snippet, Key, Milestone].freeze + def index + @counts = Gitlab::Database::Count.approximate_counts(COUNTED_ITEMS) @projects = Project.order_id_desc.without_deleted.with_route.limit(10) @users = User.order_id_desc.limit(10) @groups = Group.order_id_desc.with_route.limit(10) diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb index 6b0b22f8e73..c2c5ad61e01 100644 --- a/app/controllers/projects/clusters/gcp_controller.rb +++ b/app/controllers/projects/clusters/gcp_controller.rb @@ -1,9 +1,8 @@ class Projects::Clusters::GcpController < Projects::ApplicationController before_action :authorize_read_cluster! - before_action :authorize_google_api, except: [:login] - before_action :authorize_google_project_billing, only: [:new, :create] before_action :authorize_create_cluster!, only: [:new, :create] - before_action :verify_billing, only: [:create] + before_action :authorize_google_api, except: :login + helper_method :token_in_session def login begin @@ -37,21 +36,6 @@ class Projects::Clusters::GcpController < Projects::ApplicationController private - def verify_billing - case google_project_billing_status - when nil - flash.now[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.') - when false - flash.now[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" } - when true - return - end - - @cluster = ::Clusters::Cluster.new(create_params) - - render :new - end - def create_params params.require(:cluster).permit( :enabled, @@ -75,18 +59,8 @@ class Projects::Clusters::GcpController < Projects::ApplicationController end end - def authorize_google_project_billing - redis_token_key = CheckGcpProjectBillingWorker.store_session_token(token_in_session) - CheckGcpProjectBillingWorker.perform_async(redis_token_key) - end - - def google_project_billing_status - CheckGcpProjectBillingWorker.get_billing_state(token_in_session) - end - def token_in_session - @token_in_session ||= - session[GoogleApi::CloudPlatform::Client.session_key_for_token] + session[GoogleApi::CloudPlatform::Client.session_key_for_token] end def expires_at_in_session diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 1b0751f48c5..242e6491456 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -14,6 +14,8 @@ class Projects::WikisController < Projects::ApplicationController def show @page = @project_wiki.find_page(params[:id], params[:version_id]) + view_param = @project_wiki.empty? ? params[:view] : 'create' + if @page render 'show' elsif file = @project_wiki.find_file(params[:id], params[:version_id]) @@ -26,12 +28,12 @@ class Projects::WikisController < Projects::ApplicationController disposition: 'inline', filename: file.name ) - else - return render('empty') unless can?(current_user, :create_wiki, @project) - + elsif can?(current_user, :create_wiki, @project) && view_param == 'create' @page = build_page(title: params[:id]) render 'edit' + else + render 'empty' end end diff --git a/app/finders/group_projects_finder.rb b/app/finders/group_projects_finder.rb index f73cf8adb4d..b6bdb2b7b0f 100644 --- a/app/finders/group_projects_finder.rb +++ b/app/finders/group_projects_finder.rb @@ -39,25 +39,15 @@ class GroupProjectsFinder < ProjectsFinder end def collection_with_user - if group.users.include?(current_user) - if only_shared? - [shared_projects] - elsif only_owned? - [owned_projects] - else - [shared_projects, owned_projects] - end + if only_shared? + [shared_projects.public_or_visible_to_user(current_user)] + elsif only_owned? + [owned_projects.public_or_visible_to_user(current_user)] else - if only_shared? - [shared_projects.public_or_visible_to_user(current_user)] - elsif only_owned? - [owned_projects.public_or_visible_to_user(current_user)] - else - [ - owned_projects.public_or_visible_to_user(current_user), - shared_projects.public_or_visible_to_user(current_user) - ] - end + [ + owned_projects.public_or_visible_to_user(current_user), + shared_projects.public_or_visible_to_user(current_user) + ] end end diff --git a/app/helpers/count_helper.rb b/app/helpers/count_helper.rb index 24ee62e68ba..5cd98f40f78 100644 --- a/app/helpers/count_helper.rb +++ b/app/helpers/count_helper.rb @@ -1,5 +1,9 @@ module CountHelper - def approximate_count_with_delimiters(model) - number_with_delimiter(Gitlab::Database::Count.approximate_count(model)) + def approximate_count_with_delimiters(count_data, model) + count = count_data[model] + + raise "Missing model #{model} from count data" unless count + + number_with_delimiter(count) end end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 7754c34d6f0..a84a39235d8 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -11,6 +11,7 @@ module NavHelper class_name = page_gutter_class class_name << 'page-with-contextual-sidebar' if defined?(@left_sidebar) && @left_sidebar class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @left_sidebar + class_name -= ['right-sidebar-expanded'] if defined?(@right_sidebar) && !@right_sidebar class_name end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index fa54eafd3a3..55078e1a2d2 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -257,6 +257,9 @@ module ProjectsHelper if project.builds_enabled? && can?(current_user, :read_pipeline, project) nav_tabs << :pipelines + end + + if can?(current_user, :read_environment, project) || can?(current_user, :read_cluster, project) nav_tabs << :operations end diff --git a/app/models/concerns/diff_file.rb b/app/models/concerns/diff_file.rb new file mode 100644 index 00000000000..72332072012 --- /dev/null +++ b/app/models/concerns/diff_file.rb @@ -0,0 +1,9 @@ +module DiffFile + extend ActiveSupport::Concern + + def to_hash + keys = Gitlab::Git::Diff::SERIALIZE_KEYS - [:diff] + + as_json(only: keys).merge(diff: diff).with_indifferent_access + end +end diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index 616a626419b..d752d5bcdee 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -3,6 +3,7 @@ # A note of this type can be resolvable. class DiffNote < Note include NoteOnDiff + include Gitlab::Utils::StrongMemoize NOTEABLE_TYPES = %w(MergeRequest Commit).freeze @@ -12,7 +13,6 @@ class DiffNote < Note validates :original_position, presence: true validates :position, presence: true - validates :diff_line, presence: true, if: :on_text? validates :line_code, presence: true, line_code: true, if: :on_text? validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } validate :positions_complete @@ -23,6 +23,7 @@ class DiffNote < Note before_validation :update_position, on: :create, if: :on_text? before_validation :set_line_code, if: :on_text? after_save :keep_around_commits + after_commit :create_diff_file, on: :create def discussion_class(*) DiffDiscussion @@ -53,21 +54,25 @@ class DiffNote < Note position.position_type == "image" end + def create_diff_file + return unless should_create_diff_file? + + diff_file = fetch_diff_file + diff_line = diff_file.line_for_position(self.original_position) + + creation_params = diff_file.diff.to_hash + .except(:too_large) + .merge(diff: diff_file.diff_hunk(diff_line)) + + create_note_diff_file(creation_params) + end + def diff_file - @diff_file ||= - begin - if created_at_diff?(noteable.diff_refs) - # We're able to use the already persisted diffs (Postgres) if we're - # presenting a "current version" of the MR discussion diff. - # So no need to make an extra Gitaly diff request for it. - # As an extra benefit, the returned `diff_file` already - # has `highlighted_diff_lines` data set from Redis on - # `Diff::FileCollection::MergeRequestDiff`. - noteable.diffs(paths: original_position.paths, expanded: true).diff_files.first - else - original_position.diff_file(self.project.repository) - end - end + strong_memoize(:diff_file) do + enqueue_diff_file_creation_job if should_create_diff_file? + + fetch_diff_file + end end def diff_line @@ -98,6 +103,38 @@ class DiffNote < Note private + def enqueue_diff_file_creation_job + # Avoid enqueuing multiple file creation jobs at once for a note (i.e. + # parallel calls to `DiffNote#diff_file`). + lease = Gitlab::ExclusiveLease.new("note_diff_file_creation:#{id}", timeout: 1.hour.to_i) + return unless lease.try_obtain + + CreateNoteDiffFileWorker.perform_async(id) + end + + def should_create_diff_file? + on_text? && note_diff_file.nil? && self == discussion.first_note + end + + def fetch_diff_file + if note_diff_file + diff = Gitlab::Git::Diff.new(note_diff_file.to_hash) + Gitlab::Diff::File.new(diff, + repository: project.repository, + diff_refs: original_position.diff_refs) + elsif created_at_diff?(noteable.diff_refs) + # We're able to use the already persisted diffs (Postgres) if we're + # presenting a "current version" of the MR discussion diff. + # So no need to make an extra Gitaly diff request for it. + # As an extra benefit, the returned `diff_file` already + # has `highlighted_diff_lines` data set from Redis on + # `Diff::FileCollection::MergeRequestDiff`. + noteable.diffs(paths: original_position.paths, expanded: true).diff_files.first + else + original_position.diff_file(self.project.repository) + end + end + def supported? for_commit? || self.noteable.has_complete_diff_refs? end diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index 189942c5ad8..f7f930e86ed 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -24,12 +24,9 @@ class InternalId < ActiveRecord::Base # # The operation locks the record and gathers a `ROW SHARE` lock (in PostgreSQL). # As such, the increment is atomic and safe to be called concurrently. - # - # If a `maximum_iid` is passed in, this overrides the incremented value if it's - # greater than that. This can be used to correct the increment value if necessary. - def increment_and_save!(maximum_iid) + def increment_and_save! lock! - self.last_value = [(last_value || 0) + 1, (maximum_iid || 0) + 1].max + self.last_value = (last_value || 0) + 1 save! last_value end @@ -93,16 +90,7 @@ class InternalId < ActiveRecord::Base # and increment its last value # # Note this will acquire a ROW SHARE lock on the InternalId record - - # Note we always calculate the maximum iid present here and - # pass it in to correct the InternalId entry if it's last_value is off. - # - # This can happen in a transition phase where both `AtomicInternalId` and - # `NonatomicInternalId` code runs (e.g. during a deploy). - # - # This is subject to be cleaned up with the 10.8 release: - # https://gitlab.com/gitlab-org/gitlab-ce/issues/45389. - (lookup || create_record).increment_and_save!(maximum_iid) + (lookup || create_record).increment_and_save! end end @@ -128,15 +116,11 @@ class InternalId < ActiveRecord::Base InternalId.create!( **scope, usage: usage_value, - last_value: maximum_iid + last_value: init.call(subject) || 0 ) end rescue ActiveRecord::RecordNotUnique lookup end - - def maximum_iid - @maximum_iid ||= init.call(subject) || 0 - end end end diff --git a/app/models/merge_request_diff_file.rb b/app/models/merge_request_diff_file.rb index 1199ff5af22..cd8ba6b904d 100644 --- a/app/models/merge_request_diff_file.rb +++ b/app/models/merge_request_diff_file.rb @@ -1,5 +1,6 @@ class MergeRequestDiffFile < ActiveRecord::Base include Gitlab::EncodingHelper + include DiffFile belongs_to :merge_request_diff @@ -12,10 +13,4 @@ class MergeRequestDiffFile < ActiveRecord::Base def diff binary? ? super.unpack('m0').first : super end - - def to_hash - keys = Gitlab::Git::Diff::SERIALIZE_KEYS - [:diff] - - as_json(only: keys).merge(diff: diff).with_indifferent_access - end end diff --git a/app/models/note.rb b/app/models/note.rb index 109405d3f17..02f7a9b1e4f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -63,6 +63,7 @@ class Note < ActiveRecord::Base has_many :todos has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_one :system_note_metadata + has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id delegate :gfm_reference, :local_reference, to: :noteable delegate :name, to: :project, prefix: true @@ -100,7 +101,8 @@ class Note < ActiveRecord::Base scope :inc_author_project, -> { includes(:project, :author) } scope :inc_author, -> { includes(:author) } scope :inc_relations_for_view, -> do - includes(:project, :author, :updated_by, :resolved_by, :award_emoji, :system_note_metadata) + includes(:project, :author, :updated_by, :resolved_by, :award_emoji, + :system_note_metadata, :note_diff_file) end scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) } diff --git a/app/models/note_diff_file.rb b/app/models/note_diff_file.rb new file mode 100644 index 00000000000..e688018a6d9 --- /dev/null +++ b/app/models/note_diff_file.rb @@ -0,0 +1,7 @@ +class NoteDiffFile < ActiveRecord::Base + include DiffFile + + belongs_to :diff_note, inverse_of: :note_diff_file + + validates :diff_note, presence: true +end diff --git a/app/services/check_gcp_project_billing_service.rb b/app/services/check_gcp_project_billing_service.rb deleted file mode 100644 index ea82b61b279..00000000000 --- a/app/services/check_gcp_project_billing_service.rb +++ /dev/null @@ -1,11 +0,0 @@ -class CheckGcpProjectBillingService - def execute(token) - client = GoogleApi::CloudPlatform::Client.new(token, nil) - client.projects_list.select do |project| - begin - client.projects_get_billing_info(project.project_id).billing_enabled - rescue - end - end - end -end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index f2a8afccdeb..3fd27d9acdc 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -11,7 +11,7 @@ module ObjectStorage ObjectStorageUnavailable = Class.new(StandardError) DIRECT_UPLOAD_TIMEOUT = 4.hours - TMP_UPLOAD_PATH = 'tmp/upload'.freeze + TMP_UPLOAD_PATH = 'tmp/uploads'.freeze module Store LOCAL = 1 diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index c37a89237f0..0f2524047e3 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -27,7 +27,7 @@ .form-check = level %span.form-text.text-muted#restricted-visibility-help - Selected levels cannot be used by non-admin users for projects or snippets. + Selected levels cannot be used by non-admin users for groups, projects or snippets. If the public level is restricted, user profiles are only visible to logged in users. .form-group.row = f.label :import_sources, class: 'col-form-label col-sm-2' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 3df4ce93fa8..3cdeb103bb8 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -12,7 +12,7 @@ = link_to admin_projects_path do %h3.text-center Projects: - = approximate_count_with_delimiters(Project) + = approximate_count_with_delimiters(@counts, Project) %hr = link_to('New project', new_project_path, class: "btn btn-new") .col-sm-4 @@ -21,7 +21,7 @@ = link_to admin_users_path do %h3.text-center Users: - = approximate_count_with_delimiters(User) + = approximate_count_with_delimiters(@counts, User) = render_if_exists 'users_statistics' %hr = link_to 'New user', new_admin_user_path, class: "btn btn-new" @@ -31,7 +31,7 @@ = link_to admin_groups_path do %h3.text-center Groups: - = approximate_count_with_delimiters(Group) + = approximate_count_with_delimiters(@counts, Group) %hr = link_to 'New group', new_admin_group_path, class: "btn btn-new" .row @@ -42,31 +42,31 @@ %p Forks %span.light.float-right - = approximate_count_with_delimiters(ForkedProjectLink) + = approximate_count_with_delimiters(@counts, ForkedProjectLink) %p Issues %span.light.float-right - = approximate_count_with_delimiters(Issue) + = approximate_count_with_delimiters(@counts, Issue) %p Merge Requests %span.light.float-right - = approximate_count_with_delimiters(MergeRequest) + = approximate_count_with_delimiters(@counts, MergeRequest) %p Notes %span.light.float-right - = approximate_count_with_delimiters(Note) + = approximate_count_with_delimiters(@counts, Note) %p Snippets %span.light.float-right - = approximate_count_with_delimiters(Snippet) + = approximate_count_with_delimiters(@counts, Snippet) %p SSH Keys %span.light.float-right - = approximate_count_with_delimiters(Key) + = approximate_count_with_delimiters(@counts, Key) %p Milestones %span.light.float-right - = approximate_count_with_delimiters(Milestone) + = approximate_count_with_delimiters(@counts, Milestone) %p Active Users %span.light.float-right diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 96ed63937fa..cae2df4699e 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -1,78 +1,39 @@ - breadcrumb_title "General Settings" - @content_class = "limit-container-width" unless fluid_layout - -.card.prepend-top-default - .card-header - Group settings - .card-body - = form_for @group, html: { multipart: true, class: "gl-show-field-errors" }, authenticity_token: true do |f| - = form_errors(@group) - = render 'shared/group_form', f: f - - .form-group.row - .offset-sm-2.col-sm-10 - .avatar-container.s160 - = group_icon(@group, alt: '', class: 'avatar group-avatar s160') - %p.light - - if @group.avatar? - You can change the group avatar here - - else - You can upload a group avatar here - = render 'shared/choose_group_avatar_button', f: f - - if @group.avatar? - %hr - = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _("Avatar will be removed. Are you sure?")}, method: :delete, class: "btn btn-danger btn-inverted" - - = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group - - .form-group.row - .offset-sm-2.col-sm-10 - = render 'shared/allow_request_access', form: f - - .form-group.row - %label.col-form-label.col-sm-2 - = s_("GroupSettings|Share with group lock") - .col-sm-10 - .form-check - = f.label :share_with_group_lock do - = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group) - %strong - - group_link = link_to @group.name, group_path(@group) - = s_("GroupSettings|Prevent sharing a project within %{group} with other groups").html_safe % { group: group_link } - %br - %span.descr= share_with_group_lock_help_text(@group) - - = render 'group_admin_settings', f: f - - .form-actions - = f.submit 'Save group', class: "btn btn-save" - -.card.bg-danger - .card-header Remove group - .card-body - = form_tag(@group, method: :delete) do - %p - Removing group will cause all child projects and resources to be removed. - %br - %strong Removed group can not be restored! - - .form-actions - = button_to 'Remove group', '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_group_message(@group) } - -- if supports_nested_groups? - .card.bg-warning - .card-header Transfer group - .card-body - = form_for @group, url: transfer_group_path(@group), method: :put do |f| - .form-group - = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: "Search groups", data: { data: parent_group_options(@group) } }) - = hidden_field_tag 'new_parent_group_id' - - %ul - %li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}. - %li You can only transfer the group to a group you manage. - %li You will need to update your local repositories to point to the new location. - %li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility. - = f.submit 'Transfer group', class: "btn btn-warning" +- expanded = Rails.env.test? + + +%section.settings.gs-general.no-animate#js-general-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('General') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Update your group name, description, avatar, and other general settings.') + .settings-content + = render 'groups/settings/general' + +%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Permissions') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Enable or disable certain group features and choose access levels.') + .settings-content + = render 'groups/settings/permissions' + +%section.settings.gs-advanced.no-animate#js-advanced-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('Advanced') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _('Perform advanced options such as changing path, transferring, or removing the group.') + .settings-content + = render 'groups/settings/advanced' = render 'shared/confirm_modal', phrase: @group.path diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml new file mode 100644 index 00000000000..b7c673db705 --- /dev/null +++ b/app/views/groups/settings/_advanced.html.haml @@ -0,0 +1,49 @@ +.sub-section + %h4.warning-title Change group path + = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + = form_errors(@group) + .form-group + %p + Changing group path can have unintended side effects. + = succeed '.' do + = link_to 'Learn more', help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank' + + .input-group.gl-field-error-anchor + .group-root-path.input-group-prepend.has-tooltip{ title: group_path(@group), :'data-placement' => 'bottom' } + .input-group-text + %span>= root_url + - if parent + %strong= parent.full_path + '/' + = f.hidden_field :parent_id + = f.text_field :path, placeholder: 'open-source', class: 'form-control', + autofocus: local_assigns[:autofocus] || false, required: true, + pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, + title: 'Please choose a group path with no special characters.', + "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" + + = f.submit 'Change group path', class: 'btn btn-warning' + +.sub-section + %h4.danger-title Remove group + = form_tag(@group, method: :delete) do + %p + Removing group will cause all child projects and resources to be removed. + %br + %strong Removed group can not be restored! + + = button_to 'Remove group', '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(@group) } + +- if supports_nested_groups? + .sub-section + %h4.warning-title Transfer group + = form_for @group, url: transfer_group_path(@group), method: :put do |f| + .form-group + = dropdown_tag('Select parent group', options: { toggle_class: 'js-groups-dropdown', title: 'Parent Group', filter: true, dropdown_class: 'dropdown-open-top dropdown-group-transfer', placeholder: 'Search groups', data: { data: parent_group_options(@group) } }) + = hidden_field_tag 'new_parent_group_id' + + %ul + %li Be careful. Changing a group's parent can have unintended #{link_to 'side effects', 'https://docs.gitlab.com/ce/user/project/index.html#redirects-when-changing-repository-paths', target: 'blank'}. + %li You can only transfer the group to a group you manage. + %li You will need to update your local repositories to point to the new location. + %li If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility. + = f.submit 'Transfer group', class: 'btn btn-warning' diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml new file mode 100644 index 00000000000..64786d24266 --- /dev/null +++ b/app/views/groups/settings/_general.html.haml @@ -0,0 +1,38 @@ += form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + = form_errors(@group) + + %fieldset + .row + .form-group.col-md-9 + = f.label :name, class: 'label-light' do + Group name + = f.text_field :name, class: 'form-control' + + .form-group.col-md-3 + = f.label :id, class: 'label-light' do + Group ID + = f.text_field :id, class: 'form-control', readonly: true + + .form-group + = f.label :description, class: 'label-light' do + Group description + %span.light (optional) + = f.text_area :description, class: 'form-control', rows: 3, maxlength: 250 + + = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group + + .form-group.row + .col-sm-12 + .avatar-container.s160 + = group_icon(@group, alt: '', class: 'avatar group-avatar s160') + %p.light + - if @group.avatar? + You can change the group avatar here + - else + You can upload a group avatar here + = render 'shared/choose_group_avatar_button', f: f + - if @group.avatar? + %hr + = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-danger btn-inverted' + + = f.submit 'Save group', class: 'btn btn-success' diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml new file mode 100644 index 00000000000..15a5ecf791c --- /dev/null +++ b/app/views/groups/settings/_permissions.html.haml @@ -0,0 +1,28 @@ += form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| + = form_errors(@group) + + %fieldset + = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group + + .form-group.row + .offset-sm-2.col-sm-10 + = render 'shared/allow_request_access', form: f + + .form-group.row + %label.col-form-label.col-sm-2 + = s_('GroupSettings|Share with group lock') + .col-sm-10 + .form-check + = f.label :share_with_group_lock do + = f.check_box :share_with_group_lock, disabled: !can_change_share_with_group_lock?(@group) + %strong + - group_link = link_to @group.name, group_path(@group) + = s_('GroupSettings|Prevent sharing a project within %{group} with other groups').html_safe % { group: group_link } + %br + %span.descr= share_with_group_lock_help_text(@group) + + = render 'groups/group_admin_settings', f: f + + = render_if_exists 'groups/member_lock_setting', f: f, group: @group + + = f.submit 'Save group', class: 'btn btn-success' diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 082e1b7befa..383d955d71f 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -6,7 +6,7 @@ %section.settings#secret-variables.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - = _('Secret variables') + = _('Variables') = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.btn-default.js-settings-toggle{ type: "button" } = expanded ? _('Collapse') : _('Expand') diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml index 5739a57dcfe..be58a844f70 100644 --- a/app/views/projects/clusters/gcp/_form.html.haml +++ b/app/views/projects/clusters/gcp/_form.html.haml @@ -1,8 +1,10 @@ += javascript_include_tag 'https://apis.google.com/js/api.js' + %p - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page} -= form_for @cluster, html: { class: 'prepend-top-20' }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| += form_for @cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| = form_errors(@cluster) .form-group = field.label :name, s_('ClusterIntegration|Kubernetes cluster name') @@ -14,13 +16,25 @@ = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| .form-group = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') - = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer') - = provider_gcp_field.text_field :gcp_project_id, class: 'form-control', placeholder: s_('ClusterIntegration|Project ID') + .js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } } + = provider_gcp_field.hidden_field :gcp_project_id + .dropdown + %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } + %span.dropdown-toggle-text + = _('Select project') + = icon('chevron-down') + %span.help-block .form-group = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone') = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') - = provider_gcp_field.text_field :zone, class: 'form-control', placeholder: 'us-central1-a' + .js-gcp-zone-dropdown-entry-point + = provider_gcp_field.hidden_field :zone + .dropdown + %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } + %span.dropdown-toggle-text + = _('Select project to choose zone') + = icon('chevron-down') .form-group = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes') @@ -28,8 +42,13 @@ .form-group = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type') - = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') - = provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4' + .js-gcp-machine-type-dropdown-entry-point + = provider_gcp_field.hidden_field :machine_type + .dropdown + %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } + %span.dropdown-toggle-text + = _('Select project and zone to choose machine type') + = icon('chevron-down') .form-group - = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'btn btn-success' + = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 7d8dd58e7e0..ed17bd4f7dc 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -42,7 +42,7 @@ %section.settings.no-animate{ class: ('expanded' if expanded) } .settings-header %h4 - = _('Secret variables') + = _('Variables') = link_to icon('question-circle'), help_page_path('ci/variables/README', anchor: 'secret-variables'), target: '_blank', rel: 'noopener noreferrer' %button.btn.js-settings-toggle{ type: 'button' } = expanded ? 'Collapse' : 'Expand' diff --git a/app/views/projects/wikis/empty.html.haml b/app/views/projects/wikis/empty.html.haml index d6e568bac94..62fa6e1907b 100644 --- a/app/views/projects/wikis/empty.html.haml +++ b/app/views/projects/wikis/empty.html.haml @@ -1,6 +1,4 @@ - page_title _("Wiki") +- @right_sidebar = false -%h3.page-title= s_("Wiki|Empty page") -%hr -.error_message - = s_("WikiEmptyPageError|You are not allowed to create wiki pages") += render 'shared/empty_states/wikis' diff --git a/app/views/shared/empty_states/_wikis.html.haml b/app/views/shared/empty_states/_wikis.html.haml new file mode 100644 index 00000000000..fabb1f39a34 --- /dev/null +++ b/app/views/shared/empty_states/_wikis.html.haml @@ -0,0 +1,30 @@ +- layout_path = 'shared/empty_states/wikis_layout' + +- if can?(current_user, :create_wiki, @project) + - create_path = project_wiki_path(@project, params[:id], { view: 'create' }) + - create_link = link_to s_('WikiEmpty|Create your first page'), create_path, class: 'btn btn-new', title: s_('WikiEmpty|Create your first page') + + = render layout: layout_path, locals: { image_path: 'illustrations/wiki_login_empty.svg' } do + %h4 + = s_('WikiEmpty|The wiki lets you write documentation for your project') + %p.text-left + = s_("WikiEmpty|A wiki is where you can store all the details about your project. This can include why you've created it, it's principles, how to use it, and so on.") + = create_link + +- elsif can?(current_user, :read_issue, @project) + - issues_link = link_to s_('WikiEmptyIssueMessage|issue tracker'), project_issues_path(@project) + - new_issue_link = link_to s_('WikiEmpty|Suggest wiki improvement'), new_project_issue_path(@project), class: 'btn btn-new', title: s_('WikiEmptyIssueMessage|Suggest wiki improvement') + + = render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do + %h4 + = s_('WikiEmpty|This project has no wiki pages') + %p.text-left + = s_('WikiEmptyIssueMessage|You must be a project member in order to add wiki pages. If you have suggestions for how to improve the wiki for this project, consider opening an issue in the %{issues_link}.').html_safe % { issues_link: issues_link } + = new_issue_link + +- else + = render layout: layout_path, locals: { image_path: 'illustrations/wiki_logout_empty.svg' } do + %h4 + = s_('WikiEmpty|This project has no wiki pages') + %p + = s_('WikiEmpty|You must be a project member in order to add wiki pages.') diff --git a/app/views/shared/empty_states/_wikis_layout.html.haml b/app/views/shared/empty_states/_wikis_layout.html.haml new file mode 100644 index 00000000000..6fae6104ca2 --- /dev/null +++ b/app/views/shared/empty_states/_wikis_layout.html.haml @@ -0,0 +1,7 @@ +.row.empty-state + .col-xs-12 + .svg-content + = image_tag image_path + .col-xs-12 + .text-content.text-center + = yield diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index b8c3b3e4aa5..becd1c4884e 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -99,6 +99,8 @@ = _('Time tracking') = icon('spinner spin') + = render_if_exists 'shared/milestones/weight', milestone: milestone + .block.merge-requests .sidebar-collapsed-icon.has-tooltip{ title: milestone_merge_requests_tooltip_text(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } %strong diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index ee0e35cedc6..320e3788a0f 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -48,6 +48,8 @@ - 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} += render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project + - if is_dynamic_milestone .table-holder %table.table diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index b6433eb3eff..93e57512edb 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -24,7 +24,6 @@ - gcp_cluster:cluster_provision - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation -- gcp_cluster:check_gcp_project_billing - gcp_cluster:cluster_wait_for_ingress_ip_address - github_import_advance_stage @@ -115,3 +114,4 @@ - upload_checksum - web_hook - repository_update_remote_mirror +- create_note_diff_file diff --git a/app/workers/check_gcp_project_billing_worker.rb b/app/workers/check_gcp_project_billing_worker.rb deleted file mode 100644 index 363f81590ab..00000000000 --- a/app/workers/check_gcp_project_billing_worker.rb +++ /dev/null @@ -1,92 +0,0 @@ -require 'securerandom' - -class CheckGcpProjectBillingWorker - include ApplicationWorker - include ClusterQueue - - LEASE_TIMEOUT = 3.seconds.to_i - SESSION_KEY_TIMEOUT = 5.minutes - BILLING_TIMEOUT = 1.hour - BILLING_CHANGED_LABELS = { state_transition: nil }.freeze - - def self.get_session_token(token_key) - Gitlab::Redis::SharedState.with do |redis| - redis.get(get_redis_session_key(token_key)) - end - end - - def self.store_session_token(token) - generate_token_key.tap do |token_key| - Gitlab::Redis::SharedState.with do |redis| - redis.set(get_redis_session_key(token_key), token, ex: SESSION_KEY_TIMEOUT) - end - end - end - - def self.get_billing_state(token) - Gitlab::Redis::SharedState.with do |redis| - value = redis.get(redis_shared_state_key_for(token)) - ActiveRecord::Type::Boolean.new.type_cast_from_user(value) - end - end - - def perform(token_key) - return unless token_key - - token = self.class.get_session_token(token_key) - return unless token - return unless try_obtain_lease_for(token) - - billing_enabled_state = !CheckGcpProjectBillingService.new.execute(token).empty? - update_billing_change_counter(self.class.get_billing_state(token), billing_enabled_state) - self.class.set_billing_state(token, billing_enabled_state) - end - - private - - def self.generate_token_key - SecureRandom.uuid - end - - def self.get_redis_session_key(token_key) - "gitlab:gcp:session:#{token_key}" - end - - def self.redis_shared_state_key_for(token) - "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled" - end - - def self.set_billing_state(token, value) - Gitlab::Redis::SharedState.with do |redis| - redis.set(redis_shared_state_key_for(token), value, ex: BILLING_TIMEOUT) - end - end - - def try_obtain_lease_for(token) - Gitlab::ExclusiveLease - .new("check_gcp_project_billing_worker:#{token.hash}", timeout: LEASE_TIMEOUT) - .try_obtain - end - - def billing_changed_counter - @billing_changed_counter ||= Gitlab::Metrics.counter( - :gcp_billing_change_count, - "Counts the number of times a GCP project changed billing_enabled state from false to true", - BILLING_CHANGED_LABELS - ) - end - - def state_transition(previous_state, current_state) - if previous_state.nil? && !current_state - 'no_billing' - elsif previous_state.nil? && current_state - 'with_billing' - elsif !previous_state && current_state - 'billing_configured' - end - end - - def update_billing_change_counter(previous_state, current_state) - billing_changed_counter.increment(state_transition: state_transition(previous_state, current_state)) - end -end diff --git a/app/workers/create_note_diff_file_worker.rb b/app/workers/create_note_diff_file_worker.rb new file mode 100644 index 00000000000..624b638a24e --- /dev/null +++ b/app/workers/create_note_diff_file_worker.rb @@ -0,0 +1,9 @@ +class CreateNoteDiffFileWorker + include ApplicationWorker + + def perform(diff_note_id) + diff_note = DiffNote.find(diff_note_id) + + diff_note.create_diff_file + end +end |