diff options
Diffstat (limited to 'app')
20 files changed, 559 insertions, 105 deletions
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index 797fd0e7e19..b03ee12aef3 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -17,10 +17,13 @@ import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; + import DateTimePicker from './date_time_picker/date_time_picker.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import GroupEmptyState from './group_empty_state.vue'; +import DashboardsDropdown from './dashboards_dropdown.vue'; + import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getTimeDiff, getAddMetricTrackingOptions } from '../utils'; import { metricStates } from '../constants'; @@ -31,16 +34,18 @@ export default { components: { VueDraggable, PanelType, - GraphGroup, - EmptyState, - GroupEmptyState, Icon, GlButton, GlDropdown, GlDropdownItem, GlFormGroup, GlModal, + DateTimePicker, + GraphGroup, + EmptyState, + GroupEmptyState, + DashboardsDropdown, }, directives: { GlModal: GlModalDirective, @@ -83,6 +88,10 @@ export default { type: String, required: true, }, + defaultBranch: { + type: String, + required: true, + }, metricsEndpoint: { type: String, required: true, @@ -140,6 +149,11 @@ export default { required: false, default: invalidUrl, }, + dashboardsEndpoint: { + type: String, + required: false, + default: invalidUrl, + }, currentDashboard: { type: String, required: false, @@ -199,9 +213,6 @@ export default { selectedDashboard() { return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; }, - selectedDashboardText() { - return this.selectedDashboard.display_name; - }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; }, @@ -223,6 +234,7 @@ export default { environmentsEndpoint: this.environmentsEndpoint, deploymentsEndpoint: this.deploymentsEndpoint, dashboardEndpoint: this.dashboardEndpoint, + dashboardsEndpoint: this.dashboardsEndpoint, currentDashboard: this.currentDashboard, projectPath: this.projectPath, }); @@ -314,6 +326,13 @@ export default { return !this.getMetricStates(groupKey).includes(metricStates.OK); }, getAddMetricTrackingOptions, + + selectDashboard(dashboard) { + const params = { + dashboard: dashboard.path, + }; + redirectTo(mergeUrlParams(params, window.location.href)); + }, }, addMetric: { title: s__('Metrics|Add metric'), @@ -333,21 +352,14 @@ export default { label-for="monitor-dashboards-dropdown" class="col-sm-12 col-md-6 col-lg-2" > - <gl-dropdown + <dashboards-dropdown id="monitor-dashboards-dropdown" - class="mb-0 d-flex js-dashboards-dropdown" + class="mb-0 d-flex" toggle-class="dropdown-menu-toggle" - :text="selectedDashboardText" - > - <gl-dropdown-item - v-for="dashboard in allDashboards" - :key="dashboard.path" - :active="dashboard.path === currentDashboard" - active-class="is-active" - :href="`?dashboard=${dashboard.path}`" - >{{ dashboard.display_name || dashboard.path }}</gl-dropdown-item - > - </gl-dropdown> + :default-branch="defaultBranch" + :selected-dashboard="selectedDashboard" + @selectDashboard="selectDashboard($event)" + /> </gl-form-group> <gl-form-group diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue new file mode 100644 index 00000000000..6d93eee0b4f --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -0,0 +1,139 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlLoadingIcon, + GlModalDirective, +} from '@gitlab/ui'; +import DuplicateDashboardForm from './duplicate_dashboard_form.vue'; + +const events = { + selectDashboard: 'selectDashboard', +}; + +export default { + components: { + GlAlert, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + GlModal, + GlLoadingIcon, + DuplicateDashboardForm, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + selectedDashboard: { + type: Object, + required: false, + default: () => ({}), + }, + defaultBranch: { + type: String, + required: true, + }, + }, + data() { + return { + alert: null, + loading: false, + form: {}, + }; + }, + computed: { + ...mapState('monitoringDashboard', ['allDashboards']), + isSystemDashboard() { + return this.selectedDashboard.system_dashboard; + }, + selectedDashboardText() { + return this.selectedDashboard.display_name; + }, + }, + methods: { + ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), + selectDashboard(dashboard) { + this.$emit(events.selectDashboard, dashboard); + }, + ok(bvModalEvt) { + // Prevent modal from hiding in case submit fails + bvModalEvt.preventDefault(); + + this.loading = true; + this.alert = null; + this.duplicateSystemDashboard(this.form) + .then(createdDashboard => { + this.loading = false; + this.alert = null; + + // Trigger hide modal as submit is successful + this.$refs.duplicateDashboardModal.hide(); + + // Dashboards in the default branch become available immediately. + // Not so in other branches, so we refresh the current dashboard + const dashboard = + this.form.branch === this.defaultBranch ? createdDashboard : this.selectedDashboard; + this.$emit(events.selectDashboard, dashboard); + }) + .catch(error => { + this.loading = false; + this.alert = error; + }); + }, + hide() { + this.alert = null; + }, + formChange(form) { + this.form = form; + }, + }, +}; +</script> +<template> + <gl-dropdown toggle-class="dropdown-menu-toggle" :text="selectedDashboardText"> + <gl-dropdown-item + v-for="dashboard in allDashboards" + :key="dashboard.path" + :active="dashboard.path === selectedDashboard.path" + active-class="is-active" + @click="selectDashboard(dashboard)" + > + {{ dashboard.display_name || dashboard.path }} + </gl-dropdown-item> + + <template v-if="isSystemDashboard"> + <gl-dropdown-divider /> + + <gl-modal + ref="duplicateDashboardModal" + modal-id="duplicateDashboardModal" + :title="s__('Metrics|Duplicate dashboard')" + ok-variant="success" + @ok="ok" + @hide="hide" + > + <gl-alert v-if="alert" class="mb-3" variant="danger" @dismiss="alert = null"> + {{ alert }} + </gl-alert> + <duplicate-dashboard-form + :dashboard="selectedDashboard" + :default-branch="defaultBranch" + @change="formChange" + /> + <template #modal-ok> + <gl-loading-icon v-if="loading" inline color="light" /> + {{ loading ? s__('Metrics|Duplicating...') : s__('Metrics|Duplicate') }} + </template> + </gl-modal> + + <gl-dropdown-item ref="duplicateDashboardItem" v-gl-modal="'duplicateDashboardModal'"> + {{ s__('Metrics|Duplicate dashboard') }} + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue new file mode 100644 index 00000000000..e678957c1e5 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -0,0 +1,138 @@ +<script> +import { __, s__, sprintf } from '~/locale'; +import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui'; + +const defaultFileName = dashboard => dashboard.path.split('/').reverse()[0]; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlFormRadioGroup, + GlFormTextarea, + }, + props: { + dashboard: { + type: Object, + required: true, + }, + defaultBranch: { + type: String, + required: true, + }, + }, + radioVals: { + /* Use the default branch (e.g. master) */ + DEFAULT: 'DEFAULT', + /* Create a new branch */ + NEW: 'NEW', + }, + data() { + return { + form: { + dashboard: this.dashboard.path, + fileName: defaultFileName(this.dashboard), + commitMessage: '', + }, + branchName: '', + branchOption: this.$options.radioVals.NEW, + branchOptions: [ + { + value: this.$options.radioVals.DEFAULT, + html: sprintf( + __('Commit to %{branchName} branch'), + { + branchName: `<strong>${this.defaultBranch}</strong>`, + }, + false, + ), + }, + { value: this.$options.radioVals.NEW, text: __('Create new branch') }, + ], + }; + }, + computed: { + defaultCommitMsg() { + return sprintf(s__('Metrics|Create custom dashboard %{fileName}'), { + fileName: this.form.fileName, + }); + }, + fileNameState() { + // valid if empty or *.yml + return !(this.form.fileName && !this.form.fileName.endsWith('.yml')); + }, + fileNameFeedback() { + return !this.fileNameState ? s__('The file name should have a .yml extension') : ''; + }, + }, + mounted() { + this.change(); + }, + methods: { + change() { + this.$emit('change', { + ...this.form, + commitMessage: this.form.commitMessage || this.defaultCommitMsg, + branch: + this.branchOption === this.$options.radioVals.NEW ? this.branchName : this.defaultBranch, + }); + }, + focus(option) { + if (option === this.$options.radioVals.NEW) { + this.$nextTick(() => { + this.$refs.branchName.$el.focus(); + }); + } + }, + }, +}; +</script> +<template> + <form @change="change"> + <p class="text-muted"> + {{ + s__(`Metrics|You can save a copy of this dashboard to your repository + so it can be customized. Select a file name and branch to + save it.`) + }} + </p> + <gl-form-group + ref="fileNameFormGroup" + :label="__('File name')" + :state="fileNameState" + :invalid-feedback="fileNameFeedback" + label-size="sm" + label-for="fileName" + > + <gl-form-input id="fileName" ref="fileName" v-model="form.fileName" :required="true" /> + </gl-form-group> + <gl-form-group :label="__('Branch')" label-size="sm" label-for="branch"> + <gl-form-radio-group + ref="branchOption" + v-model="branchOption" + :checked="$options.radioVals.NEW" + :stacked="true" + :options="branchOptions" + @change="focus" + /> + <gl-form-input + v-show="branchOption === $options.radioVals.NEW" + id="branchName" + ref="branchName" + v-model="branchName" + /> + </gl-form-group> + <gl-form-group + :label="__('Commit message (optional)')" + label-size="sm" + label-for="commitMessage" + > + <gl-form-textarea + id="commitMessage" + ref="commitMessage" + v-model="form.commitMessage" + :placeholder="defaultCommitMsg" + /> + </gl-form-group> + </form> +</template> diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index fce89b450e4..61cd8621902 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -214,5 +214,29 @@ export const setPanelGroupMetrics = ({ commit }, data) => { commit(types.SET_PANEL_GROUP_METRICS, data); }; +export const duplicateSystemDashboard = ({ state }, payload) => { + const params = { + dashboard: payload.dashboard, + file_name: payload.fileName, + branch: payload.branch, + commit_message: payload.commitMessage, + }; + + return axios + .post(state.dashboardsEndpoint, params) + .then(response => response.data) + .then(data => data.dashboard) + .catch(error => { + const { response } = error; + if (response && response.data && response.data.error) { + throw sprintf(s__('Metrics|There was an error creating the dashboard. %{error}'), { + error: response.data.error, + }); + } else { + throw s__('Metrics|There was an error creating the dashboard.'); + } + }); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 0b848de9562..506a30ae619 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -175,6 +175,7 @@ export default { state.environmentsEndpoint = endpoints.environmentsEndpoint; state.deploymentsEndpoint = endpoints.deploymentsEndpoint; state.dashboardEndpoint = endpoints.dashboardEndpoint; + state.dashboardsEndpoint = endpoints.dashboardsEndpoint; state.currentDashboard = endpoints.currentDashboard; state.projectPath = endpoints.projectPath; }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue index e03b1e6d6a6..34866cdfa6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import DeploymentInfo from './deployment_info.vue'; import DeploymentViewButton from './deployment_view_button.vue'; import DeploymentStopButton from './deployment_stop_button.vue'; @@ -14,9 +14,6 @@ export default { DeploymentStopButton, DeploymentViewButton, }, - directives: { - GlTooltip: GlTooltipDirective, - }, props: { deployment: { type: Object, @@ -43,6 +40,14 @@ export default { }, }, computed: { + appButtonText() { + return { + text: this.isCurrent ? s__('Review App|View app') : s__('Review App|View latest app'), + tooltip: this.isCurrent + ? '' + : __('View the latest successful deployment to this environment'), + }; + }, canBeManuallyDeployed() { return this.computedDeploymentStatus === MANUAL_DEPLOY; }, @@ -55,9 +60,6 @@ export default { hasExternalUrls() { return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); }, - hasPreviousDeployment() { - return Boolean(!this.isCurrent && this.deployment.deployed_at); - }, isCurrent() { return this.computedDeploymentStatus === SUCCESS; }, @@ -89,7 +91,7 @@ export default { <!-- show appropriate version of review app button --> <deployment-view-button v-if="hasExternalUrls" - :is-current="isCurrent" + :app-button-text="appButtonText" :deployment="deployment" :show-visual-review-app="showVisualReviewApp" :visual-review-app-metadata="visualReviewAppMeta" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index 9965e3d5203..18d4073ecd4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -11,12 +11,12 @@ export default { import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), }, props: { - deployment: { + appButtonText: { type: Object, required: true, }, - isCurrent: { - type: Boolean, + deployment: { + type: Object, required: true, }, showVisualReviewApp: { @@ -60,7 +60,7 @@ export default { > <template slot="mainAction" slot-scope="slotProps"> <review-app-link - :is-current="isCurrent" + :display="appButtonText" :link="deploymentExternalUrl" :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" /> @@ -85,7 +85,7 @@ export default { </filtered-search-dropdown> <template v-else> <review-app-link - :is-current="isCurrent" + :display="appButtonText" :link="deploymentExternalUrl" css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue index 1550ec0f21e..c38c41f13b6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue @@ -1,18 +1,21 @@ <script> -import { __ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { Icon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { cssClass: { type: String, required: true, }, - isCurrent: { - type: Boolean, + display: { + type: Object, required: true, }, link: { @@ -20,15 +23,12 @@ export default { required: true, }, }, - computed: { - linkText() { - return this.isCurrent ? __('View app') : __('View previous app'); - }, - }, }; </script> <template> <a + v-gl-tooltip + :title="display.tooltip" :href="link" target="_blank" rel="noopener noreferrer nofollow" @@ -36,6 +36,6 @@ export default { data-track-event="open_review_app" data-track-label="review_app" > - {{ linkText }} <icon class="fgray" name="external-link" /> + {{ display.text }} <icon class="fgray" name="external-link" /> </a> </template> diff --git a/app/controllers/projects/performance_monitoring/dashboards_controller.rb b/app/controllers/projects/performance_monitoring/dashboards_controller.rb index c873fcd6c8a..2d872b78096 100644 --- a/app/controllers/projects/performance_monitoring/dashboards_controller.rb +++ b/app/controllers/projects/performance_monitoring/dashboards_controller.rb @@ -7,90 +7,53 @@ module Projects before_action :check_repository_available! before_action :validate_required_params! - before_action :validate_dashboard_template! - before_action :authorize_push! - USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT - DASHBOARD_TEMPLATES = { - ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH - }.freeze + rescue_from ActionController::ParameterMissing do |exception| + respond_error(http_status: :bad_request, message: _('Request parameter %{param} is missing.') % { param: exception.param }) + end def create - result = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute + result = ::Metrics::Dashboard::CloneDashboardService.new(project, current_user, dashboard_params).execute if result[:status] == :success - respond_success + respond_success(result) else - respond_error(result[:message]) + respond_error(result) end end private - def respond_success + def respond_success(result) + set_web_ide_link_notice(result.dig(:dashboard, :path)) respond_to do |format| - format.html { redirect_to ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path) } - format.json { render json: { redirect_to: ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path) }, status: :created } + format.json { render status: result.delete(:http_status), json: result } end end - def respond_error(message) - flash[:alert] = message - + def respond_error(result) respond_to do |format| - format.html { redirect_back_or_default(default: namespace_project_environments_path) } - format.json { render json: { error: message }, status: :bad_request } + format.json { render json: { error: result[:message] }, status: result[:http_status] } end end - def authorize_push! - access_denied!(%q(You can't commit to this project)) unless user_access(project).can_push_to_branch?(params[:branch]) + def set_web_ide_link_notice(new_dashboard_path) + web_ide_link_start = "<a href=\"#{ide_edit_path(project, redirect_safe_branch_name, new_dashboard_path)}\">" + message = _("Your dashboard has been copied. You can %{web_ide_link_start}edit it here%{web_ide_link_end}.") % { web_ide_link_start: web_ide_link_start, web_ide_link_end: "</a>" } + flash[:notice] = message.html_safe end def validate_required_params! - params.require(%i(branch file_name dashboard)) - end - - def validate_dashboard_template! - access_denied! unless dashboard_template - end - - def dashboard_attrs - { - commit_message: commit_message, - file_path: new_dashboard_path, - file_content: new_dashboard_content, - encoding: 'text', - branch_name: params[:branch], - start_branch: repository.branch_exists?(params[:branch]) ? params[:branch] : project.default_branch - } - end - - def commit_message - params[:commit_message] || "Create custom dashboard #{params[:file_name]}" - end - - def new_dashboard_path - File.join(USER_DASHBOARDS_DIR, params[:file_name]) - end - - def new_dashboard_content - File.read(Rails.root.join(dashboard_template)) - end - - def dashboard_template - dashboard_templates[params[:dashboard]] - end - - def dashboard_templates - DASHBOARD_TEMPLATES + params.require(%i(branch file_name dashboard commit_message)) end def redirect_safe_branch_name repository.find_branch(params[:branch]).name end + + def dashboard_params + params.permit(%i(branch file_name dashboard commit_message)).to_h + end end end end - -Projects::PerformanceMonitoring::DashboardsController.prepend_if_ee('EE::Projects::PerformanceMonitoring::DashboardsController') diff --git a/app/graphql/resolvers/environments_resolver.rb b/app/graphql/resolvers/environments_resolver.rb new file mode 100644 index 00000000000..868abef98eb --- /dev/null +++ b/app/graphql/resolvers/environments_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class EnvironmentsResolver < BaseResolver + argument :name, GraphQL::STRING_TYPE, + required: false, + description: 'Name of the environment' + + argument :search, GraphQL::STRING_TYPE, + required: false, + description: 'Search query' + + type Types::EnvironmentType, null: true + + alias_method :project, :object + + def resolve(**args) + return unless project.present? + + EnvironmentsFinder.new(project, context[:current_user], args).find + end + end +end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb new file mode 100644 index 00000000000..ad65caa24a6 --- /dev/null +++ b/app/graphql/types/environment_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class EnvironmentType < BaseObject + graphql_name 'Environment' + description 'Describes where code is deployed for a project' + + authorize :read_environment + + field :name, GraphQL::STRING_TYPE, null: false, + description: 'Human-readable name of the environment' + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the environment' + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 31cde7b6d48..5ece4926951 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -138,6 +138,12 @@ module Types description: 'Issues of the project', resolver: Resolvers::IssuesResolver + field :environments, + Types::EnvironmentType.connection_type, + null: true, + description: 'Environments of the project', + resolver: Resolvers::EnvironmentsResolver + field :issue, Types::IssueType, null: true, diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 59972118ae3..993c18f9229 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -29,8 +29,10 @@ module EnvironmentsHelper "empty-no-data-small-svg-path" => image_path('illustrations/chart-empty-state-small.svg'), "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'), "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json), + "dashboards-endpoint" => project_performance_monitoring_dashboards_path(project, format: :json), "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json), "deployments-endpoint" => project_environment_deployments_path(project, environment, format: :json), + "default-branch" => project.default_branch, "environments-endpoint": project_environments_path(project, format: :json), "project-path" => project_path(project), "tags-path" => project_tags_path(project), diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 663389050d1..3943d991c87 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -197,6 +197,10 @@ module Ci AutoMergeProcessWorker.perform_async(merge_request.id) end + + if pipeline.auto_devops_source? + self.class.auto_devops_pipelines_completed_total.increment(status: pipeline.status) + end end end @@ -330,6 +334,10 @@ module Ci ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created waiting_for_resource preparing pending] end + def self.auto_devops_pipelines_completed_total + @auto_devops_pipelines_completed_total ||= Gitlab::Metrics.counter(:auto_devops_pipelines_completed_total, 'Number of completed auto devops pipelines') + end + def stages_count statuses.select(:stage).distinct.count end diff --git a/app/models/user.rb b/app/models/user.rb index 6442e74bbe3..4bba4d47b8f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -307,6 +307,8 @@ class User < ApplicationRecord scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :external, -> { where(external: true) } scope :active, -> { with_state(:active).non_internal } + scope :active_without_ghosts, -> { with_state(:active).without_ghosts } + scope :without_ghosts, -> { where('ghost IS NOT TRUE') } scope :deactivated, -> { with_state(:deactivated).non_internal } scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) } scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } @@ -470,7 +472,7 @@ class User < ApplicationRecord when 'deactivated' deactivated else - active + active_without_ghosts end end @@ -614,7 +616,7 @@ class User < ApplicationRecord end def self.non_internal - where('ghost IS NOT TRUE') + without_ghosts end # diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb new file mode 100644 index 00000000000..b2ec44cb814 --- /dev/null +++ b/app/services/metrics/dashboard/clone_dashboard_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +# Copies system dashboard definition in .yml file into designated +# .yml file inside `.gitlab/dashboards` +module Metrics + module Dashboard + class CloneDashboardService < ::BaseService + ALLOWED_FILE_TYPE = '.yml' + USER_DASHBOARDS_DIR = ::Metrics::Dashboard::ProjectDashboardService::DASHBOARD_ROOT + + def self.allowed_dashboard_templates + @allowed_dashboard_templates ||= Set[::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH].freeze + end + + def execute + catch(:error) do + throw(:error, error(_(%q(You can't commit to this project)), :forbidden)) unless push_authorized? + + result = ::Files::CreateService.new(project, current_user, dashboard_attrs).execute + throw(:error, wrap_error(result)) unless result[:status] == :success + + repository.refresh_method_caches([:metrics_dashboard]) + success(result.merge(http_status: :created, dashboard: dashboard_details)) + end + end + + private + + def dashboard_attrs + { + commit_message: params[:commit_message], + file_path: new_dashboard_path, + file_content: new_dashboard_content, + encoding: 'text', + branch_name: branch, + start_branch: repository.branch_exists?(branch) ? branch : project.default_branch + } + end + + def dashboard_details + { + path: new_dashboard_path, + display_name: ::Metrics::Dashboard::ProjectDashboardService.name_for_path(new_dashboard_path), + default: false, + system_dashboard: false + } + end + + def push_authorized? + Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch) + end + + def dashboard_template + @dashboard_template ||= begin + throw(:error, error(_('Not found.'), :not_found)) unless self.class.allowed_dashboard_templates.include?(params[:dashboard]) + + params[:dashboard] + end + end + + def branch + @branch ||= begin + throw(:error, error(_('There was an error creating the dashboard, branch name is invalid.'), :bad_request)) unless valid_branch_name? + throw(:error, error(_('There was an error creating the dashboard, branch named: %{branch} already exists.') % { branch: params[:branch] }, :bad_request)) unless new_or_default_branch? # temporary validation for first UI iteration + + params[:branch] + end + end + + def new_or_default_branch? + !repository.branch_exists?(params[:branch]) || project.default_branch == params[:branch] + end + + def valid_branch_name? + Gitlab::GitRefValidator.validate(params[:branch]) + end + + def new_dashboard_path + @new_dashboard_path ||= File.join(USER_DASHBOARDS_DIR, file_name) + end + + def file_name + @file_name ||= begin + throw(:error, error(_('The file name should have a .yml extension'), :bad_request)) unless target_file_type_valid? + + File.basename(params[:file_name]) + end + end + + def target_file_type_valid? + File.extname(params[:file_name]) == ALLOWED_FILE_TYPE + end + + def new_dashboard_content + File.read(Rails.root.join(dashboard_template)) + end + + def repository + @repository ||= project.repository + end + + def wrap_error(result) + if result[:message] == 'A file with this name already exists' + error(_("A file with '%{file_name}' already exists in %{branch} branch") % { file_name: file_name, branch: branch }, :bad_request) + else + result + end + end + end + end +end + +Metrics::Dashboard::CloneDashboardService.prepend_if_ee('EE::Metrics::Dashboard::CloneDashboardService') diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 3c6ad899d1e..ecbabab3e7f 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -9,7 +9,7 @@ = nav_link(html_options: { class: active_when(params[:filter].nil?) }) do = link_to admin_users_path do = s_('AdminUsers|Active') - %small.badge.badge-pill= limited_counter_with_delimiter(User.active) + %small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts) = nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do = link_to admin_users_path(filter: "admins") do = s_('AdminUsers|Admins') diff --git a/app/views/groups/settings/ci_cd/show.html.haml b/app/views/groups/settings/ci_cd/show.html.haml index 81bd15ed287..8c9b859e127 100644 --- a/app/views/groups/settings/ci_cd/show.html.haml +++ b/app/views/groups/settings/ci_cd/show.html.haml @@ -44,8 +44,10 @@ = expanded ? _('Collapse') : _('Expand') %p - auto_devops_url = help_page_path('topics/autodevops/index') + - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } - = s_('GroupSettings|Auto DevOps will automatically build, test and deploy your application based on a predefined Continuous Integration and Delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end}').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } + - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } + = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } .settings-content = render 'groups/settings/ci_cd/auto_devops_form', group: @group diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index a027dca1b56..88bb0a97487 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -44,7 +44,7 @@ - if group_sidebar_link?(:contribution_analytics) = nav_link(path: 'analytics#show') do - = link_to group_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do + = link_to group_contribution_analytics_path(@group), title: _('Contribution Analytics'), data: { placement: 'right', qa_selector: 'contribution_analytics_link' } do %span = _('Contribution Analytics') diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index 5a6c8079543..a65afeecc17 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -23,8 +23,11 @@ %button.btn.btn-default.js-settings-toggle{ type: 'button' } = expanded ? _('Collapse') : _('Expand') %p - = s_('CICD|Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration.') - = link_to s_('CICD|Learn more about Auto DevOps'), help_page_path('topics/autodevops/index.md') + - auto_devops_url = help_page_path('topics/autodevops/index') + - quickstart_url = help_page_path('topics/autodevops/quick_start_guide') + - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } + - quickstart_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: quickstart_url } + = s_('AutoDevOps|Auto DevOps can automatically build, test, and deploy applications based on predefined continuous integration and delivery configuration. %{auto_devops_start}Learn more about Auto DevOps%{auto_devops_end} or use our %{quickstart_start}quick start guide%{quickstart_end} to get started right away.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe, quickstart_start: quickstart_start, quickstart_end: '</a>'.html_safe } .settings-content = render 'autodevops_form', auto_devops_enabled: @project.auto_devops_enabled? |