diff options
Diffstat (limited to 'app')
-rw-r--r-- | app/assets/javascripts/monitoring/stores/actions.js | 186 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/mutation_types.js | 11 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/mutations.js | 6 | ||||
-rw-r--r-- | app/assets/javascripts/notes/components/sort_discussion.vue | 13 | ||||
-rw-r--r-- | app/assets/javascripts/snippets/components/snippet_blob_view.vue | 15 | ||||
-rw-r--r-- | app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql | 4 | ||||
-rw-r--r-- | app/assets/javascripts/vue_shared/components/local_storage_sync.vue | 39 | ||||
-rw-r--r-- | app/assets/stylesheets/framework/dropdowns.scss | 8 | ||||
-rw-r--r-- | app/models/clusters/cluster.rb | 1 | ||||
-rw-r--r-- | app/models/environment.rb | 1 | ||||
-rw-r--r-- | app/models/metrics/dashboard/annotation.rb | 33 | ||||
-rw-r--r-- | app/policies/group_policy.rb | 4 | ||||
-rw-r--r-- | app/policies/metrics/dashboard/annotation_policy.rb | 9 | ||||
-rw-r--r-- | app/policies/project_policy.rb | 4 | ||||
-rw-r--r-- | app/services/metrics/dashboard/annotations/create_service.rb | 80 | ||||
-rw-r--r-- | app/services/metrics/dashboard/annotations/delete_service.rb | 43 |
16 files changed, 369 insertions, 88 deletions
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 2e4987b7349..acc09fa6305 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -12,6 +12,20 @@ import { s__, sprintf } from '../../locale'; import { PROMETHEUS_TIMEOUT } from '../constants'; +function prometheusMetricQueryParams(timeRange) { + const { start, end } = convertToFixedRange(timeRange); + + const timeDiff = (new Date(end) - new Date(start)) / 1000; + const minStep = 60; + const queryDataPoints = 600; + + return { + start_time: start, + end_time: end, + step: Math.max(minStep, Math.ceil(timeDiff / queryDataPoints)), + }; +} + function backOffRequest(makeRequestCallback) { return backOff((next, stop) => { makeRequestCallback() @@ -26,6 +40,20 @@ function backOffRequest(makeRequestCallback) { }, PROMETHEUS_TIMEOUT); } +function getPrometheusMetricResult(prometheusEndpoint, params) { + return backOffRequest(() => axios.get(prometheusEndpoint, { params })) + .then(res => res.data) + .then(response => { + if (response.status === 'error') { + throw new Error(response.error); + } + + return response.data.result; + }); +} + +// Setup + export const setGettingStartedEmptyState = ({ commit }) => { commit(types.SET_GETTING_STARTED_EMPTY_STATE); }; @@ -47,56 +75,26 @@ export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; -export const requestMetricsDashboard = ({ commit }) => { - commit(types.REQUEST_METRICS_DATA); -}; -export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response, params }) => { - const { all_dashboards, dashboard, metrics_data } = response; - - commit(types.SET_ALL_DASHBOARDS, all_dashboards); - commit(types.RECEIVE_METRICS_DATA_SUCCESS, dashboard); - commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data)); - - return dispatch('fetchPrometheusMetrics', params); -}; -export const receiveMetricsDashboardFailure = ({ commit }, error) => { - commit(types.RECEIVE_METRICS_DATA_FAILURE, error); -}; - -export const receiveDeploymentsDataSuccess = ({ commit }, data) => - commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); -export const receiveDeploymentsDataFailure = ({ commit }) => - commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE); -export const requestEnvironmentsData = ({ commit }) => commit(types.REQUEST_ENVIRONMENTS_DATA); -export const receiveEnvironmentsDataSuccess = ({ commit }, data) => - commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); -export const receiveEnvironmentsDataFailure = ({ commit }) => - commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); +// All Data export const fetchData = ({ dispatch }) => { - dispatch('fetchDashboard'); - dispatch('fetchDeploymentsData'); dispatch('fetchEnvironmentsData'); + dispatch('fetchDashboard'); }; +// Metrics dashboard + export const fetchDashboard = ({ state, commit, dispatch }) => { dispatch('requestMetricsDashboard'); const params = {}; - - if (state.timeRange) { - const { start, end } = convertToFixedRange(state.timeRange); - params.start_time = start; - params.end_time = end; - } - if (state.currentDashboard) { params.dashboard = state.currentDashboard; } return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) - .then(response => dispatch('receiveMetricsDashboardSuccess', { response, params })) + .then(response => dispatch('receiveMetricsDashboardSuccess', { response })) .catch(error => { Sentry.captureException(error); @@ -120,61 +118,43 @@ export const fetchDashboard = ({ state, commit, dispatch }) => { }); }; -function fetchPrometheusResult(prometheusEndpoint, params) { - return backOffRequest(() => axios.get(prometheusEndpoint, { params })) - .then(res => res.data) - .then(response => { - if (response.status === 'error') { - throw new Error(response.error); - } - - return response.data.result; - }); -} - -/** - * Returns list of metrics in data.result - * {"status":"success", "data":{"resultType":"matrix","result":[]}} - * - * @param {metric} metric - */ -export const fetchPrometheusMetric = ({ commit }, { metric, params }) => { - const { start_time, end_time } = params; - const timeDiff = (new Date(end_time) - new Date(start_time)) / 1000; +export const requestMetricsDashboard = ({ commit }) => { + commit(types.REQUEST_METRICS_DASHBOARD); +}; +export const receiveMetricsDashboardSuccess = ({ commit, dispatch }, { response }) => { + const { all_dashboards, dashboard, metrics_data } = response; - const minStep = 60; - const queryDataPoints = 600; - const step = metric.step ? metric.step : Math.max(minStep, Math.ceil(timeDiff / queryDataPoints)); + commit(types.SET_ALL_DASHBOARDS, all_dashboards); + commit(types.RECEIVE_METRICS_DASHBOARD_SUCCESS, dashboard); + commit(types.SET_ENDPOINTS, convertObjectPropsToCamelCase(metrics_data)); - const queryParams = { - start_time, - end_time, - step, - }; + return dispatch('fetchPrometheusMetrics'); +}; +export const receiveMetricsDashboardFailure = ({ commit }, error) => { + commit(types.RECEIVE_METRICS_DASHBOARD_FAILURE, error); +}; - commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); +// Metrics - return fetchPrometheusResult(metric.prometheusEndpointPath, queryParams) - .then(result => { - commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result }); - }) - .catch(error => { - Sentry.captureException(error); +/** + * Loads timeseries data: Prometheus data points and deployment data from the project + * @param {Object} Vuex store + */ +export const fetchPrometheusMetrics = ({ state, dispatch, getters }) => { + dispatch('fetchDeploymentsData'); - commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metricId, error }); - // Continue to throw error so the dashboard can notify using createFlash - throw error; - }); -}; + if (!state.timeRange) { + createFlash(s__(`Metrics|Invalid time range, please verify.`), 'warning'); + return Promise.reject(); + } -export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, params) => { - commit(types.REQUEST_METRICS_DATA); + const defaultQueryParams = prometheusMetricQueryParams(state.timeRange); const promises = []; state.dashboard.panelGroups.forEach(group => { group.panels.forEach(panel => { panel.metrics.forEach(metric => { - promises.push(dispatch('fetchPrometheusMetric', { metric, params })); + promises.push(dispatch('fetchPrometheusMetric', { metric, defaultQueryParams })); }); }); }); @@ -192,6 +172,35 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch, getters }, par }); }; +/** + * Returns list of metrics in data.result + * {"status":"success", "data":{"resultType":"matrix","result":[]}} + * + * @param {metric} metric + */ +export const fetchPrometheusMetric = ({ commit }, { metric, defaultQueryParams }) => { + const queryParams = { ...defaultQueryParams }; + if (metric.step) { + queryParams.step = metric.step; + } + + commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); + + return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams) + .then(result => { + commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result }); + }) + .catch(error => { + Sentry.captureException(error); + + commit(types.RECEIVE_METRIC_RESULT_FAILURE, { metricId: metric.metricId, error }); + // Continue to throw error so the dashboard can notify using createFlash + throw error; + }); +}; + +// Deployments + export const fetchDeploymentsData = ({ state, dispatch }) => { if (!state.deploymentsEndpoint) { return Promise.resolve([]); @@ -212,6 +221,14 @@ export const fetchDeploymentsData = ({ state, dispatch }) => { createFlash(s__('Metrics|There was an error getting deployment information.')); }); }; +export const receiveDeploymentsDataSuccess = ({ commit }, data) => { + commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data); +}; +export const receiveDeploymentsDataFailure = ({ commit }) => { + commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE); +}; + +// Environments export const fetchEnvironmentsData = ({ state, dispatch }) => { dispatch('requestEnvironmentsData'); @@ -241,6 +258,17 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { createFlash(s__('Metrics|There was an error getting environments information.')); }); }; +export const requestEnvironmentsData = ({ commit }) => { + commit(types.REQUEST_ENVIRONMENTS_DATA); +}; +export const receiveEnvironmentsDataSuccess = ({ commit }, data) => { + commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data); +}; +export const receiveEnvironmentsDataFailure = ({ commit }) => { + commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); +}; + +// Dashboard manipulation /** * Set a new array of metrics to a panel group diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 09eb7dc1673..9a3489d53d7 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -1,19 +1,24 @@ -export const REQUEST_METRICS_DATA = 'REQUEST_METRICS_DATA'; -export const RECEIVE_METRICS_DATA_SUCCESS = 'RECEIVE_METRICS_DATA_SUCCESS'; -export const RECEIVE_METRICS_DATA_FAILURE = 'RECEIVE_METRICS_DATA_FAILURE'; +// Dashboard "skeleton", groups, panels and metrics +export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; +export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS'; +export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; +// Git project deployments export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE'; +// Environments export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS'; export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; +// Metric data points export const REQUEST_METRIC_RESULT = 'REQUEST_METRIC_RESULT'; export const RECEIVE_METRIC_RESULT_SUCCESS = 'RECEIVE_METRIC_RESULT_SUCCESS'; export const RECEIVE_METRIC_RESULT_FAILURE = 'RECEIVE_METRIC_RESULT_FAILURE'; +// Parameters and other information export const SET_TIME_RANGE = 'SET_TIME_RANGE'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 2e10d189087..0a7bb47d533 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -74,18 +74,18 @@ export default { /** * Dashboard panels structure and global state */ - [types.REQUEST_METRICS_DATA](state) { + [types.REQUEST_METRICS_DASHBOARD](state) { state.emptyState = 'loading'; state.showEmptyState = true; }, - [types.RECEIVE_METRICS_DATA_SUCCESS](state, dashboard) { + [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboard) { state.dashboard = mapToDashboardViewModel(dashboard); if (!state.dashboard.panelGroups.length) { state.emptyState = 'noData'; } }, - [types.RECEIVE_METRICS_DATA_FAILURE](state, error) { + [types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) { state.emptyState = error ? 'unableToConnect' : 'noData'; state.showEmptyState = true; }, diff --git a/app/assets/javascripts/notes/components/sort_discussion.vue b/app/assets/javascripts/notes/components/sort_discussion.vue index 3f82ddde3ef..4a7543819eb 100644 --- a/app/assets/javascripts/notes/components/sort_discussion.vue +++ b/app/assets/javascripts/notes/components/sort_discussion.vue @@ -1,7 +1,9 @@ +gs <script> import { GlIcon } from '@gitlab/ui'; import { mapActions, mapGetters } from 'vuex'; import { __ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import Tracking from '~/tracking'; import { ASC, DESC } from '../constants'; @@ -14,16 +16,20 @@ export default { SORT_OPTIONS, components: { GlIcon, + LocalStorageSync, }, mixins: [Tracking.mixin()], computed: { - ...mapGetters(['sortDirection']), + ...mapGetters(['sortDirection', 'noteableType']), selectedOption() { return SORT_OPTIONS.find(({ key }) => this.sortDirection === key); }, dropdownText() { return this.selectedOption.text; }, + storageKey() { + return `sort_direction_${this.noteableType.toLowerCase()}`; + }, }, methods: { ...mapActions(['setDiscussionSortDirection']), @@ -44,6 +50,11 @@ export default { <template> <div class="mr-2 d-inline-block align-bottom full-width-mobile"> + <local-storage-sync + :value="sortDirection" + :storage-key="storageKey" + @input="setDiscussionSortDirection" + /> <button class="btn btn-sm js-dropdown-text" data-toggle="dropdown" aria-expanded="false"> {{ dropdownText }} <gl-icon name="chevron-down" /> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index 4703a940e08..3e3dcab70c0 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -4,6 +4,7 @@ import { SNIPPET_VISIBILITY_PUBLIC } from '../constants'; import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContent from '~/blob/components/blob_content.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; import GetSnippetBlobQuery from '../queries/snippet.blob.query.graphql'; import GetBlobContent from '../queries/snippet.blob.content.query.graphql'; @@ -16,6 +17,7 @@ export default { BlobHeader, BlobContent, GlLoadingIcon, + CloneDropdownButton, }, apollo: { blob: { @@ -72,6 +74,9 @@ export default { const { richViewer, simpleViewer } = this.blob; return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer; }, + canBeCloned() { + return this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo; + }, }, methods: { switchViewer(newViewer, respectHash = false) { @@ -90,7 +95,15 @@ export default { class="prepend-top-20 append-bottom-20" /> <article v-else class="file-holder snippet-file-content"> - <blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer" /> + <blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer"> + <template #actions> + <clone-dropdown-button + v-if="canBeCloned" + :ssh-link="snippet.sshUrlToRepo" + :http-link="snippet.httpUrlToRepo" + /> + </template> + </blob-header> <blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" /> </article> </div> diff --git a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql index e0cc6cc2dda..22aab7c7795 100644 --- a/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql +++ b/app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql @@ -7,8 +7,10 @@ fragment SnippetBase on Snippet { updatedAt visibilityLevel webUrl + httpUrlToRepo + sshUrlToRepo userPermissions { adminSnippet updateSnippet } -}
\ No newline at end of file +} diff --git a/app/assets/javascripts/vue_shared/components/local_storage_sync.vue b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue new file mode 100644 index 00000000000..b5d6b872547 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/local_storage_sync.vue @@ -0,0 +1,39 @@ +<script> +export default { + props: { + storageKey: { + type: String, + required: true, + }, + value: { + type: String, + required: false, + default: '', + }, + }, + watch: { + value(newVal) { + this.saveValue(newVal); + }, + }, + mounted() { + // On mount, trigger update if we actually have a localStorageValue + const value = this.getValue(); + + if (value && this.value !== value) { + this.$emit('input', value); + } + }, + methods: { + getValue() { + return localStorage.getItem(this.storageKey); + }, + saveValue(val) { + localStorage.setItem(this.storageKey, val); + }, + }, + render() { + return this.$slots.default; + }, +}; +</script> diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index a56505ee6e2..b6edadb05a9 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -1,6 +1,14 @@ .dropdown { position: relative; + // Once the new design (https://gitlab.com/gitlab-org/gitlab-foss/-/issues/63499/designs) + // for Snippets is introduced and Clone button is relocated, we won't + // need this style. + // Issue for the refactoring: https://gitlab.com/gitlab-org/gitlab/-/issues/213327 + &.gl-new-dropdown button.dropdown-toggle { + @include gl-display-inline-flex; + } + .btn-link { &:hover { cursor: pointer; diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 78efe2b4337..42771eaa82a 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -59,6 +59,7 @@ module Clusters has_one_cluster_application :elastic_stack has_many :kubernetes_namespaces + has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :cluster accepts_nested_attributes_for :provider_gcp, update_only: true accepts_nested_attributes_for :provider_aws, update_only: true diff --git a/app/models/environment.rb b/app/models/environment.rb index fecf13f349e..23c2296688d 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -18,6 +18,7 @@ class Environment < ApplicationRecord has_many :successful_deployments, -> { success }, class_name: 'Deployment' has_many :active_deployments, -> { active }, class_name: 'Deployment' has_many :prometheus_alerts, inverse_of: :environment + has_many :metrics_dashboard_annotations, class_name: 'Metrics::Dashboard::Annotation', inverse_of: :environment has_many :self_managed_prometheus_alert_events, inverse_of: :environment has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment' diff --git a/app/models/metrics/dashboard/annotation.rb b/app/models/metrics/dashboard/annotation.rb new file mode 100644 index 00000000000..2f1b6527742 --- /dev/null +++ b/app/models/metrics/dashboard/annotation.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Metrics + module Dashboard + class Annotation < ApplicationRecord + self.table_name = 'metrics_dashboard_annotations' + + belongs_to :environment, inverse_of: :metrics_dashboard_annotations + belongs_to :cluster, class_name: 'Clusters::Cluster', inverse_of: :metrics_dashboard_annotations + + validates :starting_at, presence: true + validates :description, presence: true, length: { maximum: 255 } + validates :dashboard_path, presence: true, length: { maximum: 255 } + validates :panel_xid, length: { maximum: 255 } + validate :single_ownership + validate :orphaned_annotation + + private + + def single_ownership + return if cluster.nil? ^ environment.nil? + + errors.add(:base, s_("Metrics::Dashboard::Annotation|Annotation can't belong to both a cluster and an environment at the same time")) + end + + def orphaned_annotation + return if cluster.present? || environment.present? + + errors.add(:base, s_("Metrics::Dashboard::Annotation|Annotation must belong to a cluster or an environment")) + end + end + end +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index abd63753908..5e252c8e564 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -75,6 +75,9 @@ class GroupPolicy < BasePolicy rule { developer }.policy do enable :admin_milestone enable :read_package + enable :create_metrics_dashboard_annotation + enable :delete_metrics_dashboard_annotation + enable :update_metrics_dashboard_annotation end rule { reporter }.policy do @@ -82,6 +85,7 @@ class GroupPolicy < BasePolicy enable :admin_label enable :admin_list enable :admin_issue + enable :read_metrics_dashboard_annotation end rule { maintainer }.policy do diff --git a/app/policies/metrics/dashboard/annotation_policy.rb b/app/policies/metrics/dashboard/annotation_policy.rb new file mode 100644 index 00000000000..25b78e104c4 --- /dev/null +++ b/app/policies/metrics/dashboard/annotation_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +module Metrics + module Dashboard + class AnnotationPolicy < BasePolicy + delegate { @subject.cluster } + delegate { @subject.environment } + end + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e694963eac0..0f5e4ac378e 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -224,6 +224,7 @@ class ProjectPolicy < BasePolicy enable :read_sentry_issue enable :update_sentry_issue enable :read_prometheus + enable :read_metrics_dashboard_annotation end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -276,6 +277,9 @@ class ProjectPolicy < BasePolicy enable :update_deployment enable :create_release enable :update_release + enable :create_metrics_dashboard_annotation + enable :delete_metrics_dashboard_annotation + enable :update_metrics_dashboard_annotation end rule { can?(:developer_access) & user_confirmed? }.policy do diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb new file mode 100644 index 00000000000..c04f4c56b51 --- /dev/null +++ b/app/services/metrics/dashboard/annotations/create_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +# Create Metrics::Dashboard::Annotation entry based on matched dashboard_path, environment, cluster +module Metrics + module Dashboard + module Annotations + class CreateService < ::BaseService + include Stepable + + steps :authorize_environment_access, + :authorize_cluster_access, + :parse_dashboard_path, + :create + + def initialize(user, params) + @user, @params = user, params + end + + def execute + execute_steps + end + + private + + attr_reader :user, :params + + def authorize_environment_access(options) + if environment.nil? || Ability.allowed?(user, :create_metrics_dashboard_annotation, project) + options[:environment] = environment + success(options) + else + error(s_('Metrics::Dashboard::Annotation|You are not authorized to create annotation for selected environment')) + end + end + + def authorize_cluster_access(options) + if cluster.nil? || Ability.allowed?(user, :create_metrics_dashboard_annotation, cluster) + options[:cluster] = cluster + success(options) + else + error(s_('Metrics::Dashboard::Annotation|You are not authorized to create annotation for selected cluster')) + end + end + + def parse_dashboard_path(options) + dashboard_path = params[:dashboard_path] + + Gitlab::Metrics::Dashboard::Finder.find_raw(project, dashboard_path: dashboard_path) + options[:dashboard_path] = dashboard_path + + success(options) + rescue Gitlab::Template::Finders::RepoTemplateFinder::FileNotFoundError + error(s_('Metrics::Dashboard::Annotation|Dashboard with requested path can not be found')) + end + + def create(options) + annotation = Annotation.new(options.slice(:environment, :cluster, :dashboard_path).merge(params.slice(:description, :starting_at, :ending_at))) + + if annotation.save + success(annotation: annotation) + else + error(annotation.errors) + end + end + + def environment + params[:environment] + end + + def cluster + params[:cluster] + end + + def project + (environment || cluster)&.project + end + end + end + end +end diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb new file mode 100644 index 00000000000..c6a6c4f5fbf --- /dev/null +++ b/app/services/metrics/dashboard/annotations/delete_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Delete Metrics::Dashboard::Annotation entry +module Metrics + module Dashboard + module Annotations + class DeleteService < ::BaseService + include Stepable + + steps :authorize_action, + :delete + + def initialize(user, annotation) + @user, @annotation = user, annotation + end + + def execute + execute_steps + end + + private + + attr_reader :user, :annotation + + def authorize_action(_options) + if Ability.allowed?(user, :delete_metrics_dashboard_annotation, annotation) + success + else + error(s_('Metrics::Dashboard::Annotation|You are not authorized to delete this annotation')) + end + end + + def delete(_options) + if annotation.destroy + success + else + error(s_('Metrics::Dashboard::Annotation|Annotation has not been deleted')) + end + end + end + end + end +end |