Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-04-07 21:09:19 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-04-07 21:09:19 +0300
commit3290d46655f07d7ae3dca788d6df9f326972ffd8 (patch)
tree0d24713e1592cdd3583257f14a52d46a22539ed1 /app
parentc6b3ec3f56fa32a0e0ed3de0d0878d25f1adaddf (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js186
-rw-r--r--app/assets/javascripts/monitoring/stores/mutation_types.js11
-rw-r--r--app/assets/javascripts/monitoring/stores/mutations.js6
-rw-r--r--app/assets/javascripts/notes/components/sort_discussion.vue13
-rw-r--r--app/assets/javascripts/snippets/components/snippet_blob_view.vue15
-rw-r--r--app/assets/javascripts/snippets/fragments/snippetBase.fragment.graphql4
-rw-r--r--app/assets/javascripts/vue_shared/components/local_storage_sync.vue39
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss8
-rw-r--r--app/models/clusters/cluster.rb1
-rw-r--r--app/models/environment.rb1
-rw-r--r--app/models/metrics/dashboard/annotation.rb33
-rw-r--r--app/policies/group_policy.rb4
-rw-r--r--app/policies/metrics/dashboard/annotation_policy.rb9
-rw-r--r--app/policies/project_policy.rb4
-rw-r--r--app/services/metrics/dashboard/annotations/create_service.rb80
-rw-r--r--app/services/metrics/dashboard/annotations/delete_service.rb43
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