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
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-07 18:09:29 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-07 18:09:29 +0300
commitf35a7a3b8e97d7af2ec1505d3fbcd6ffdd869fd2 (patch)
tree3a31002cc98598aed02c21606b21a5a123afaad2
parent896b68514b43b9646d763e67f63fbe8f9ef2f723 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/alert_management/components/alert_management_list.vue34
-rw-r--r--app/assets/javascripts/alert_management/constants.js32
-rw-r--r--app/assets/javascripts/code_navigation/store/actions.js4
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_discussion.vue8
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_note.vue120
-rw-r--r--app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue44
-rw-r--r--app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql10
-rw-r--r--app/assets/javascripts/design_management/pages/design/index.vue7
-rw-r--r--app/assets/javascripts/design_management/utils/cache_update.js2
-rw-r--r--app/assets/javascripts/design_management/utils/error_messages.js2
-rw-r--r--app/assets/javascripts/editor/editor_lite.js4
-rw-r--r--app/assets/javascripts/ide/lib/editor.js3
-rw-r--r--app/assets/javascripts/ide/lib/languages/index.js5
-rw-r--r--app/assets/javascripts/ide/lib/languages/vue.js306
-rw-r--r--app/assets/javascripts/ide/utils.js10
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue43
-rw-r--r--app/assets/javascripts/monitoring/stores/actions.js7
-rw-r--r--app/assets/javascripts/monitoring/utils.js66
-rw-r--r--app/assets/javascripts/snippets/components/snippet_description_view.vue21
-rw-r--r--app/assets/javascripts/snippets/components/snippet_title.vue14
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue15
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue1
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue25
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js6
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue9
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js1
-rw-r--r--app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue6
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/field_view.vue19
-rw-r--r--app/assets/stylesheets/page_bundles/themes/_dark.scss2
-rw-r--r--app/assets/stylesheets/pages/alerts_list.scss10
-rw-r--r--app/controllers/projects/alert_management_controller.rb3
-rw-r--r--app/graphql/types/alert_management/status_enum.rb4
-rw-r--r--app/models/alert_management/alert.rb69
-rw-r--r--app/models/merge_request_diff.rb26
-rw-r--r--app/models/namespace.rb4
-rw-r--r--app/services/alert_management/process_prometheus_alert_service.rb86
-rw-r--r--app/services/alert_management/update_alert_status_service.rb4
-rw-r--r--app/services/concerns/git/logger.rb10
-rw-r--r--app/services/merge_requests/base_service.rb26
-rw-r--r--app/services/merge_requests/rebase_service.rb8
-rw-r--r--app/services/merge_requests/squash_service.rb18
-rw-r--r--app/services/projects/prometheus/alerts/notify_service.rb11
-rw-r--r--app/views/projects/merge_requests/_widget.html.haml1
-rw-r--r--app/workers/all_queues.yml10
-rw-r--r--app/workers/concerns/chaos_queue.rb2
-rw-r--r--changelogs/unreleased/118609-design-comment-edit-comment-text.yml5
-rw-r--r--changelogs/unreleased/197344-use-cookies-with-metadata.yml5
-rw-r--r--changelogs/unreleased/212279-webide-vue-files.yml5
-rw-r--r--changelogs/unreleased/215472-single-chart-from-url.yml5
-rw-r--r--changelogs/unreleased/216031-show-accurate-error-message-when-pipelines-disabled-mr-must-succee.yml7
-rw-r--r--changelogs/unreleased/cleanup_user_highest_roles_background_migration.yml5
-rw-r--r--changelogs/unreleased/sh-fix-squash-error-handling.yml5
-rw-r--r--config/feature_categories.yml1
-rw-r--r--config/initializers/cookies_serializer.rb2
-rw-r--r--db/post_migrate/20200506125731_cleanup_user_highest_roles_population.rb23
-rw-r--r--db/structure.sql3
-rw-r--r--doc/development/documentation/styleguide.md57
-rw-r--r--lib/gitlab/alert_management/alert_params.rb17
-rw-r--r--lib/gitlab/alerting/alert.rb12
-rw-r--r--lib/gitlab/metrics/samplers/ruby_sampler.rb31
-rw-r--r--lib/gitlab/metrics/system.rb81
-rw-r--r--locale/gitlab.pot41
-rw-r--r--qa/qa/page/dashboard/snippet/show.rb4
-rw-r--r--spec/factories/alert_management/alerts.rb18
-rw-r--r--spec/finders/alert_management/alerts_finder_spec.rb12
-rw-r--r--spec/frontend/alert_management/components/alert_management_list_spec.js70
-rw-r--r--spec/frontend/code_navigation/store/actions_spec.js10
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap83
-rw-r--r--spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap15
-rw-r--r--spec/frontend/design_management/components/design_notes/design_note_spec.js106
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js50
-rw-r--r--spec/frontend/editor/editor_lite_spec.js (renamed from spec/javascripts/editor/editor_lite_spec.js)37
-rw-r--r--spec/frontend/ide/lib/editor_spec.js14
-rw-r--r--spec/frontend/ide/lib/languages/vue_spec.js92
-rw-r--r--spec/frontend/ide/utils_spec.js77
-rw-r--r--spec/frontend/monitoring/components/dashboard_panel_spec.js16
-rw-r--r--spec/frontend/monitoring/components/dashboard_spec.js86
-rw-r--r--spec/frontend/monitoring/store/actions_spec.js36
-rw-r--r--spec/frontend/monitoring/utils_spec.js144
-rw-r--r--spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap16
-rw-r--r--spec/frontend/snippets/components/snippet_description_view_spec.js27
-rw-r--r--spec/frontend/snippets/components/snippet_title_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_view_spec.js26
-rw-r--r--spec/graphql/mutations/alert_management/update_alert_status_spec.rb2
-rw-r--r--spec/graphql/resolvers/alert_management_alert_resolver_spec.rb4
-rw-r--r--spec/graphql/types/alert_management/status_enum_spec.rb17
-rw-r--r--spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js13
-rw-r--r--spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js1
-rw-r--r--spec/lib/gitlab/alert_management/alert_params_spec.rb39
-rw-r--r--spec/lib/gitlab/alerting/alert_spec.rb24
-rw-r--r--spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb18
-rw-r--r--spec/lib/gitlab/metrics/system_spec.rb113
-rw-r--r--spec/models/alert_management/alert_spec.rb195
-rw-r--r--spec/requests/api/graphql/project/alert_management/alerts_spec.rb15
-rw-r--r--spec/services/alert_management/process_prometheus_alert_service_spec.rb136
-rw-r--r--spec/services/alert_management/update_alert_status_service_spec.rb2
-rw-r--r--spec/services/merge_requests/rebase_service_spec.rb19
-rw-r--r--spec/services/merge_requests/squash_service_spec.rb40
-rw-r--r--spec/services/projects/alerting/notify_service_spec.rb2
-rw-r--r--spec/services/projects/prometheus/alerts/notify_service_spec.rb52
101 files changed, 2643 insertions, 420 deletions
diff --git a/app/assets/javascripts/alert_management/components/alert_management_list.vue b/app/assets/javascripts/alert_management/components/alert_management_list.vue
index e29835da117..f1716182e5f 100644
--- a/app/assets/javascripts/alert_management/components/alert_management_list.vue
+++ b/app/assets/javascripts/alert_management/components/alert_management_list.vue
@@ -8,10 +8,15 @@ import {
GlIcon,
GlNewDropdown,
GlNewDropdownItem,
+ GlTabs,
+ GlTab,
+ GlBadge,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import getAlerts from '../graphql/queries/getAlerts.query.graphql';
+import { ALERTS_STATUS, ALERTS_STATUS_TABS } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const tdClass = 'table-col d-flex d-md-table-cell align-items-center';
@@ -59,10 +64,11 @@ export default {
},
],
statuses: {
- triggered: s__('AlertManagement|Triggered'),
- acknowledged: s__('AlertManagement|Acknowledged'),
- resolved: s__('AlertManagement|Resolved'),
+ [ALERTS_STATUS.TRIGGERED]: s__('AlertManagement|Triggered'),
+ [ALERTS_STATUS.ACKNOWLEDGED]: s__('AlertManagement|Acknowledged'),
+ [ALERTS_STATUS.RESOLVED]: s__('AlertManagement|Resolved'),
},
+ statusTabs: ALERTS_STATUS_TABS,
components: {
GlEmptyState,
GlLoadingIcon,
@@ -73,7 +79,11 @@ export default {
GlNewDropdown,
GlNewDropdownItem,
GlIcon,
+ GlTabs,
+ GlTab,
+ GlBadge,
},
+ mixins: [glFeatureFlagsMixin()],
props: {
projectPath: {
type: String,
@@ -102,6 +112,7 @@ export default {
variables() {
return {
projectPath: this.projectPath,
+ status: this.statusFilter,
};
},
update(data) {
@@ -118,6 +129,7 @@ export default {
errored: false,
isAlertDismissed: false,
isErrorAlertDismissed: false,
+ statusFilter: this.$options.statusTabs[0].status,
};
},
computed: {
@@ -131,6 +143,11 @@ export default {
return this.$apollo.queries.alerts.loading;
},
},
+ methods: {
+ filterALertsByStatus(tabIndex) {
+ this.statusFilter = this.$options.statusTabs[tabIndex].status;
+ },
+ },
};
</script>
@@ -144,6 +161,17 @@ export default {
{{ $options.i18n.errorMsg }}
</gl-alert>
+ <gl-tabs v-if="glFeatures.alertListStatusFilteringEnabled" @input="filterALertsByStatus">
+ <gl-tab v-for="tab in $options.statusTabs" :key="tab.status">
+ <template slot="title">
+ <span>{{ tab.title }}</span>
+ <gl-badge v-if="alerts" size="sm" class="gl-tab-counter-badge">
+ {{ alerts.length }}
+ </gl-badge>
+ </template>
+ </gl-tab>
+ </gl-tabs>
+
<h4 class="d-block d-md-none my-3">
{{ s__('AlertManagement|Alerts') }}
</h4>
diff --git a/app/assets/javascripts/alert_management/constants.js b/app/assets/javascripts/alert_management/constants.js
new file mode 100644
index 00000000000..c95a3c29f04
--- /dev/null
+++ b/app/assets/javascripts/alert_management/constants.js
@@ -0,0 +1,32 @@
+import { s__ } from '~/locale';
+
+export const ALERTS_STATUS = {
+ OPEN: 'open',
+ TRIGGERED: 'triggered',
+ ACKNOWLEDGED: 'acknowledged',
+ RESOLVED: 'resolved',
+ ALL: 'all',
+};
+
+export const ALERTS_STATUS_TABS = [
+ {
+ title: s__('AlertManagement|Open'),
+ status: ALERTS_STATUS.OPEN,
+ },
+ {
+ title: s__('AlertManagement|Triggered'),
+ status: ALERTS_STATUS.TRIGGERED,
+ },
+ {
+ title: s__('AlertManagement|Acknowledged'),
+ status: ALERTS_STATUS.ACKNOWLEDGED,
+ },
+ {
+ title: s__('AlertManagement|Resolved'),
+ status: ALERTS_STATUS.RESOLVED,
+ },
+ {
+ title: s__('AlertManagement|All alerts'),
+ status: ALERTS_STATUS.ALL,
+ },
+];
diff --git a/app/assets/javascripts/code_navigation/store/actions.js b/app/assets/javascripts/code_navigation/store/actions.js
index f5b96145314..7b2669691bd 100644
--- a/app/assets/javascripts/code_navigation/store/actions.js
+++ b/app/assets/javascripts/code_navigation/store/actions.js
@@ -30,7 +30,9 @@ export default {
});
},
showBlobInteractionZones({ state }, path) {
- Object.values(state.data[path]).forEach(d => addInteractionClass(path, d));
+ if (state.data && state.data[path]) {
+ Object.values(state.data[path]).forEach(d => addInteractionClass(path, d));
+ }
},
showDefinition({ commit, state }, { target: el }) {
let definition;
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
index 9ba69e34494..024957abe46 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue
@@ -103,7 +103,13 @@ export default {
class="design-discussion bordered-box position-relative"
data-qa-selector="design_discussion_content"
>
- <design-note v-for="note in discussion.notes" :key="note.id" :note="note" />
+ <design-note
+ v-for="note in discussion.notes"
+ :key="note.id"
+ :note="note"
+ :markdown-preview-path="markdownPreviewPath"
+ @error="$emit('updateNoteError', $event)"
+ />
<div class="reply-wrapper">
<reply-placeholder
v-if="!isFormRendered"
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
index e4bc0889a5c..f87140b23c9 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue
@@ -1,20 +1,42 @@
<script>
+import { ApolloMutation } from 'vue-apollo';
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignReplyForm from './design_reply_form.vue';
import { findNoteId } from '../../utils/design_management_utils';
+import { hasErrors } from '../../utils/cache_update';
export default {
components: {
UserAvatarLink,
TimelineEntryItem,
TimeAgoTooltip,
+ DesignReplyForm,
+ ApolloMutation,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
},
props: {
note: {
type: Object,
required: true,
},
+ markdownPreviewPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ noteText: this.note.body,
+ isEditing: false,
+ };
},
computed: {
author() {
@@ -26,12 +48,31 @@ export default {
isNoteLinked() {
return this.$route.hash === `#note_${this.noteAnchorId}`;
},
+ mutationPayload() {
+ return {
+ id: this.note.id,
+ body: this.noteText,
+ };
+ },
},
mounted() {
if (this.isNoteLinked) {
this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
}
},
+ methods: {
+ hideForm() {
+ this.isEditing = false;
+ this.noteText = this.note.body;
+ },
+ onDone({ data }) {
+ this.hideForm();
+ if (hasErrors(data.updateNote)) {
+ this.$emit('error', data.errors[0]);
+ }
+ },
+ },
+ updateNoteMutation,
};
</script>
@@ -43,26 +84,65 @@ export default {
:img-alt="author.username"
:img-size="40"
/>
- <a
- v-once
- :href="author.webUrl"
- class="js-user-link"
- :data-user-id="author.id"
- :data-username="author.username"
- >
- <span class="note-header-author-name bold">{{ author.name }}</span>
- <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
- <span class="note-headline-light">@{{ author.username }}</span>
- </a>
- <span class="note-headline-light note-headline-meta">
- <span class="system-note-message"> <slot></slot> </span>
- <template v-if="note.createdAt">
- <span class="system-note-separator"></span>
- <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
- <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
+ <div class="d-flex justify-content-between">
+ <div>
+ <a
+ v-once
+ :href="author.webUrl"
+ class="js-user-link"
+ :data-user-id="author.id"
+ :data-username="author.username"
+ >
+ <span class="note-header-author-name bold">{{ author.name }}</span>
+ <span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
+ <span class="note-headline-light">@{{ author.username }}</span>
</a>
- </template>
- </span>
- <div class="note-text md" data-qa-selector="note_content" v-html="note.bodyHtml"></div>
+ <span class="note-headline-light note-headline-meta">
+ <span class="system-note-message"> <slot></slot> </span>
+ <template v-if="note.createdAt">
+ <span class="system-note-separator"></span>
+ <a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
+ <time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
+ </a>
+ </template>
+ </span>
+ </div>
+ <button
+ v-if="!isEditing"
+ v-gl-tooltip
+ type="button"
+ title="Edit comment"
+ class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ @click="isEditing = true"
+ >
+ <gl-icon name="pencil" class="link-highlight" />
+ </button>
+ </div>
+ <div
+ v-if="!isEditing"
+ class="note-text js-note-text md"
+ data-qa-selector="note_content"
+ v-html="note.bodyHtml"
+ ></div>
+ <apollo-mutation
+ v-else
+ #default="{ mutate, loading }"
+ :mutation="$options.updateNoteMutation"
+ :variables="{
+ input: mutationPayload,
+ }"
+ @error="$emit('error', $event)"
+ @done="onDone"
+ >
+ <design-reply-form
+ v-model="noteText"
+ :is-saving="loading"
+ :markdown-preview-path="markdownPreviewPath"
+ :is-new-comment="false"
+ class="mt-5"
+ @submitForm="mutate"
+ @cancelForm="hideForm"
+ />
+ </apollo-mutation>
</timeline-entry-item>
</template>
diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
index 3736b0204e3..40be9867fee 100644
--- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
+++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue
@@ -1,6 +1,7 @@
<script>
import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import { s__ } from '~/locale';
export default {
name: 'DesignReplyForm',
@@ -23,11 +24,42 @@ export default {
type: Boolean,
required: true,
},
+ isNewComment: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ },
+ data() {
+ return {
+ formText: this.value,
+ };
},
computed: {
hasValue() {
return this.value.trim().length > 0;
},
+ modalSettings() {
+ if (this.isNewComment) {
+ return {
+ title: s__('DesignManagement|Cancel comment confirmation'),
+ okTitle: s__('DesignManagement|Discard comment'),
+ cancelTitle: s__('DesignManagement|Keep comment'),
+ content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'),
+ };
+ }
+ return {
+ title: s__('DesignManagement|Cancel comment update confirmation'),
+ okTitle: s__('DesignManagement|Cancel changes'),
+ cancelTitle: s__('DesignManagement|Keep changes'),
+ content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'),
+ };
+ },
+ buttonText() {
+ return this.isNewComment
+ ? s__('DesignManagement|Comment')
+ : s__('DesignManagement|Save comment');
+ },
},
mounted() {
this.$refs.textarea.focus();
@@ -37,7 +69,7 @@ export default {
if (this.hasValue) this.$emit('submitForm');
},
cancelComment() {
- if (this.hasValue) {
+ if (this.hasValue && this.formText !== this.value) {
this.$refs.cancelCommentModal.show();
} else {
this.$emit('cancelForm');
@@ -85,7 +117,7 @@ export default {
data-qa-selector="save_comment_button"
@click="$emit('submitForm')"
>
- {{ __('Comment') }}
+ {{ buttonText }}
</gl-deprecated-button>
<gl-deprecated-button ref="cancelButton" @click="cancelComment">{{
__('Cancel')
@@ -94,12 +126,12 @@ export default {
<gl-modal
ref="cancelCommentModal"
ok-variant="danger"
- :title="s__('DesignManagement|Cancel comment confirmation')"
- :ok-title="s__('DesignManagement|Discard comment')"
- :cancel-title="s__('DesignManagement|Keep comment')"
+ :title="modalSettings.title"
+ :ok-title="modalSettings.okTitle"
+ :cancel-title="modalSettings.cancelTitle"
modal-id="cancel-comment-modal"
@ok="$emit('cancelForm')"
- >{{ s__('DesignManagement|Are you sure you want to cancel creating this comment?') }}
+ >{{ modalSettings.content }}
</gl-modal>
</form>
</template>
diff --git a/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
new file mode 100644
index 00000000000..d96b2f3934a
--- /dev/null
+++ b/app/assets/javascripts/design_management/graphql/mutations/update_note.mutation.graphql
@@ -0,0 +1,10 @@
+#import "../fragments/designNote.fragment.graphql"
+
+mutation updateNote($input: UpdateNoteInput!) {
+ updateNote(input: $input) {
+ note {
+ ...DesignNote
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue
index 38186bbfbf8..d51bdc4687a 100644
--- a/app/assets/javascripts/design_management/pages/design/index.vue
+++ b/app/assets/javascripts/design_management/pages/design/index.vue
@@ -32,6 +32,7 @@ import {
UPDATE_IMAGE_DIFF_NOTE_ERROR,
DESIGN_NOT_FOUND_ERROR,
DESIGN_VERSION_NOT_EXIST_ERROR,
+ UPDATE_NOTE_ERROR,
designDeletionError,
} from '../../utils/error_messages';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
@@ -231,6 +232,9 @@ export default {
onCreateImageDiffNoteError(e) {
this.onError(ADD_IMAGE_DIFF_NOTE_ERROR, e);
},
+ onUpdateNoteError(e) {
+ this.onError(UPDATE_NOTE_ERROR, e);
+ },
onDesignDiscussionError(e) {
this.onError(ADD_DISCUSSION_COMMENT_ERROR, e);
},
@@ -329,6 +333,7 @@ export default {
:discussion-index="index + 1"
:markdown-preview-path="markdownPreviewPath"
@error="onDesignDiscussionError"
+ @updateNoteError="onUpdateNoteError"
/>
<apollo-mutation
v-if="annotationCoordinates"
@@ -345,7 +350,7 @@ export default {
v-model="comment"
:is-saving="loading"
:markdown-preview-path="markdownPreviewPath"
- @submitForm="mutate()"
+ @submitForm="mutate"
@cancelForm="closeCommentForm"
/>
</apollo-mutation>
diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js
index a8d650e39ca..01c073bddc2 100644
--- a/app/assets/javascripts/design_management/utils/cache_update.js
+++ b/app/assets/javascripts/design_management/utils/cache_update.js
@@ -214,7 +214,7 @@ const onError = (data, message) => {
throw new Error(data.errors);
};
-const hasErrors = ({ errors = [] }) => errors?.length;
+export const hasErrors = ({ errors = [] }) => errors?.length;
/**
* Updates a store after design deletion
diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js
index 5e91966931a..7666c726c2f 100644
--- a/app/assets/javascripts/design_management/utils/error_messages.js
+++ b/app/assets/javascripts/design_management/utils/error_messages.js
@@ -12,6 +12,8 @@ export const UPDATE_IMAGE_DIFF_NOTE_ERROR = s__(
'DesignManagement|Could not update discussion. Please try again.',
);
+export const UPDATE_NOTE_ERROR = s__('DesignManagement|Could not update note. Please try again.');
+
export const UPLOAD_DESIGN_ERROR = s__(
'DesignManagement|Error uploading a new design. Please try again.',
);
diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js
index 663d14bcfcb..020ed6dc867 100644
--- a/app/assets/javascripts/editor/editor_lite.js
+++ b/app/assets/javascripts/editor/editor_lite.js
@@ -1,6 +1,8 @@
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
+import languages from '~/ide/lib/languages';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
+import { registerLanguages } from '~/ide/utils';
import { clearDomElement } from './utils';
export default class Editor {
@@ -17,6 +19,8 @@ export default class Editor {
};
Editor.setupMonacoTheme();
+
+ registerLanguages(...languages);
}
static setupMonacoTheme() {
diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js
index 3aff4d30d81..25224abd77c 100644
--- a/app/assets/javascripts/ide/lib/editor.js
+++ b/app/assets/javascripts/ide/lib/editor.js
@@ -7,8 +7,10 @@ import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import { themes } from './themes';
+import languages from './languages';
import keymap from './keymap.json';
import { clearDomElement } from '~/editor/utils';
+import { registerLanguages } from '../utils';
function setupThemes() {
themes.forEach(theme => {
@@ -37,6 +39,7 @@ export default class Editor {
};
setupThemes();
+ registerLanguages(...languages);
this.debouncedUpdate = debounce(() => {
this.updateDimensions();
diff --git a/app/assets/javascripts/ide/lib/languages/index.js b/app/assets/javascripts/ide/lib/languages/index.js
new file mode 100644
index 00000000000..0c85a1104fc
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/index.js
@@ -0,0 +1,5 @@
+import vue from './vue';
+
+const languages = [vue];
+
+export default languages;
diff --git a/app/assets/javascripts/ide/lib/languages/vue.js b/app/assets/javascripts/ide/lib/languages/vue.js
new file mode 100644
index 00000000000..b9ff5c5d776
--- /dev/null
+++ b/app/assets/javascripts/ide/lib/languages/vue.js
@@ -0,0 +1,306 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ * Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
+ *--------------------------------------------------------------------------------------------*/
+
+// Based on handlebars template in https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts
+// Look for "vuejs template attributes" in this file for Vue specific syntax.
+
+import { languages } from 'monaco-editor';
+
+/* eslint-disable no-useless-escape */
+/* eslint-disable @gitlab/require-i18n-strings */
+
+const EMPTY_ELEMENTS = [
+ 'area',
+ 'base',
+ 'br',
+ 'col',
+ 'embed',
+ 'hr',
+ 'img',
+ 'input',
+ 'keygen',
+ 'link',
+ 'menuitem',
+ 'meta',
+ 'param',
+ 'source',
+ 'track',
+ 'wbr',
+];
+
+const conf = {
+ wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,
+
+ comments: {
+ blockComment: ['{{!--', '--}}'],
+ },
+
+ brackets: [['<!--', '-->'], ['<', '>'], ['{{', '}}'], ['{', '}'], ['(', ')']],
+
+ autoClosingPairs: [
+ { open: '{', close: '}' },
+ { open: '[', close: ']' },
+ { open: '(', close: ')' },
+ { open: '"', close: '"' },
+ { open: "'", close: "'" },
+ ],
+
+ surroundingPairs: [
+ { open: '<', close: '>' },
+ { open: '"', close: '"' },
+ { open: "'", close: "'" },
+ ],
+
+ onEnterRules: [
+ {
+ beforeText: new RegExp(
+ `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
+ 'i',
+ ),
+ afterText: /^<\/(\w[\w\d]*)\s*>$/i,
+ action: { indentAction: languages.IndentAction.IndentOutdent },
+ },
+ {
+ beforeText: new RegExp(
+ `<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
+ 'i',
+ ),
+ action: { indentAction: languages.IndentAction.Indent },
+ },
+ ],
+};
+
+const language = {
+ defaultToken: '',
+ tokenPostfix: '',
+ // ignoreCase: true,
+
+ // The main tokenizer for our languages
+ tokenizer: {
+ root: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.root' }],
+ [/<!DOCTYPE/, 'metatag.html', '@doctype'],
+ [/<!--/, 'comment.html', '@comment'],
+ [/(<)([\w]+)(\/>)/, ['delimiter.html', 'tag.html', 'delimiter.html']],
+ [/(<)(script)/, ['delimiter.html', { token: 'tag.html', next: '@script' }]],
+ [/(<)(style)/, ['delimiter.html', { token: 'tag.html', next: '@style' }]],
+ [/(<)([:\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
+ [/(<\/)([\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
+ [/</, 'delimiter.html'],
+ [/\{/, 'delimiter.html'],
+ [/[^<{]+/], // text
+ ],
+
+ doctype: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
+ [/[^>]+/, 'metatag.content.html'],
+ [/>/, 'metatag.html', '@pop'],
+ ],
+
+ comment: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
+ [/-->/, 'comment.html', '@pop'],
+ [/[^-]+/, 'comment.content.html'],
+ [/./, 'comment.content.html'],
+ ],
+
+ otherTag: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.otherTag' }],
+ [/\/?>/, 'delimiter.html', '@pop'],
+
+ // -- BEGIN vuejs template attributes
+ [/(v-|@|:)[\w\-\.\:\[\]]+="([^"]*)"/, 'variable'],
+ [/(v-|@|:)[\w\-\.\:\[\]]+='([^']*)'/, 'variable'],
+
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+
+ [/[\w\-\.\:\[\]]+/, 'attribute.name'],
+ // -- END vuejs template attributes
+
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ ],
+
+ // -- BEGIN <script> tags handling
+
+ // After <script
+ script: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.script' }],
+ [/type/, 'attribute.name', '@scriptAfterType'],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ],
+ [/[ \t\r\n]+/], // whitespace
+ [
+ /(<\/)(script\s*)(>)/,
+ ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
+ ],
+ ],
+
+ // After <script ... type
+ scriptAfterType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterType' }],
+ [/=/, 'delimiter', '@scriptAfterTypeEquals'],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ], // cover invalid e.g. <script type>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <script ... type =
+ scriptAfterTypeEquals: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterTypeEquals' }],
+ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
+ [/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
+ [
+ />/,
+ {
+ token: 'delimiter.html',
+ next: '@scriptEmbedded.text/javascript',
+ nextEmbedded: 'text/javascript',
+ },
+ ], // cover invalid e.g. <script type=>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <script ... type = $S2
+ scriptWithCustomType: [
+ [
+ /\{\{/,
+ { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptWithCustomType.$S2' },
+ ],
+ [/>/, { token: 'delimiter.html', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ scriptEmbedded: [
+ [
+ /\{\{/,
+ {
+ token: '@rematch',
+ switchTo: '@handlebarsInEmbeddedState.scriptEmbedded.$S2',
+ nextEmbedded: '@pop',
+ },
+ ],
+ [/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
+ ],
+
+ // -- END <script> tags handling
+
+ // -- BEGIN <style> tags handling
+
+ // After <style
+ style: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.style' }],
+ [/type/, 'attribute.name', '@styleAfterType'],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }],
+ [/[ \t\r\n]+/], // whitespace
+ [
+ /(<\/)(style\s*)(>)/,
+ ['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
+ ],
+ ],
+
+ // After <style ... type
+ styleAfterType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterType' }],
+ [/=/, 'delimiter', '@styleAfterTypeEquals'],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <style ... type =
+ styleAfterTypeEquals: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterTypeEquals' }],
+ [/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
+ [/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type=>
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ // After <style ... type = $S2
+ styleWithCustomType: [
+ [/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleWithCustomType.$S2' }],
+ [/>/, { token: 'delimiter.html', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }],
+ [/"([^"]*)"/, 'attribute.value'],
+ [/'([^']*)'/, 'attribute.value'],
+ [/[\w\-]+/, 'attribute.name'],
+ [/=/, 'delimiter'],
+ [/[ \t\r\n]+/], // whitespace
+ [/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
+ ],
+
+ styleEmbedded: [
+ [
+ /\{\{/,
+ {
+ token: '@rematch',
+ switchTo: '@handlebarsInEmbeddedState.styleEmbedded.$S2',
+ nextEmbedded: '@pop',
+ },
+ ],
+ [/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
+ ],
+
+ // -- END <style> tags handling
+
+ handlebarsInSimpleState: [
+ [/\{\{\{?/, 'delimiter.handlebars'],
+ [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3' }],
+ { include: 'handlebarsRoot' },
+ ],
+
+ handlebarsInEmbeddedState: [
+ [/\{\{\{?/, 'delimiter.handlebars'],
+ [/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3', nextEmbedded: '$S3' }],
+ { include: 'handlebarsRoot' },
+ ],
+
+ handlebarsRoot: [
+ [/"[^"]*"/, 'string.handlebars'],
+ [/[#/][^\s}]+/, 'keyword.helper.handlebars'],
+ [/else\b/, 'keyword.helper.handlebars'],
+ [/[\s]+/],
+ [/[^}]/, 'variable.parameter.handlebars'],
+ ],
+ },
+};
+
+export default {
+ id: 'vue',
+ extensions: ['.vue'],
+ aliases: ['Vue', 'vue'],
+ mimetypes: ['text/x-vue-template'],
+ conf,
+ language,
+};
diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js
index 64ac539a4ff..e7615be498b 100644
--- a/app/assets/javascripts/ide/utils.js
+++ b/app/assets/javascripts/ide/utils.js
@@ -68,3 +68,13 @@ export const createPathWithExt = p => {
return `${p.substring(1, p.lastIndexOf('.') + 1 || p.length)}${ext || '.js'}`;
};
+
+export function registerLanguages(def, ...defs) {
+ if (defs.length) defs.forEach(lang => registerLanguages(lang));
+
+ const languageId = def.id;
+
+ languages.register(def);
+ languages.setMonarchTokensProvider(languageId, def.language);
+ languages.setLanguageConfiguration(languageId, def.conf);
+}
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 67835a5e356..f20674699f7 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,5 +1,5 @@
<script>
-import { debounce, pickBy } from 'lodash';
+import { debounce } from 'lodash';
import { mapActions, mapState, mapGetters } from 'vuex';
import VueDraggable from 'vuedraggable';
import {
@@ -32,7 +32,13 @@ import GroupEmptyState from './group_empty_state.vue';
import DashboardsDropdown from './dashboards_dropdown.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
-import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
+import {
+ getAddMetricTrackingOptions,
+ timeRangeToUrl,
+ timeRangeFromUrl,
+ panelToUrl,
+ expandedPanelPayloadFromUrl,
+} from '../utils';
import { metricStates } from '../constants';
import { defaultTimeRange, timeRanges } from '~/vue_shared/constants';
@@ -238,6 +244,23 @@ export default {
return !this.environmentsLoading && this.filteredEnvironments.length === 0;
},
},
+ watch: {
+ dashboard(newDashboard) {
+ try {
+ const expandedPanel = expandedPanelPayloadFromUrl(newDashboard);
+ if (expandedPanel) {
+ this.setExpandedPanel(expandedPanel);
+ }
+ } catch {
+ createFlash(
+ s__(
+ 'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.',
+ ),
+ );
+ }
+ },
+ },
+
created() {
this.setInitialState({
metricsEndpoint: this.metricsEndpoint,
@@ -299,15 +322,9 @@ export default {
// As a fallback, switch to default time range instead
this.selectedTimeRange = defaultTimeRange;
},
-
- generatePanelLink(group, graphData) {
- if (!group || !graphData) {
- return null;
- }
- const dashboard = this.currentDashboard || this.firstDashboard.path;
- const { y_label, title } = graphData;
- const params = pickBy({ dashboard, group, title, y_label }, value => value != null);
- return mergeUrlParams(params, window.location.href);
+ generatePanelUrl(groupKey, panel) {
+ const dashboardPath = this.currentDashboard || this.firstDashboard.path;
+ return panelToUrl(dashboardPath, groupKey, panel);
},
hideAddMetricModal() {
this.$refs.addMetricModal.hide();
@@ -564,7 +581,7 @@ export default {
v-show="expandedPanel.panel"
ref="expandedPanel"
:settings-path="settingsPath"
- :clipboard-text="generatePanelLink(expandedPanel.group, expandedPanel.panel)"
+ :clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)"
:graph-data="expandedPanel.panel"
:alerts-endpoint="alertsEndpoint"
:height="600"
@@ -623,7 +640,7 @@ export default {
<dashboard-panel
:settings-path="settingsPath"
- :clipboard-text="generatePanelLink(groupData.group, graphData)"
+ :clipboard-text="generatePanelUrl(groupData.group, graphData)"
:graph-data="graphData"
:alerts-endpoint="alertsEndpoint"
:prometheus-alerts-available="prometheusAlertsAvailable"
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 717f4cd9d66..1db65f5e960 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -102,6 +102,13 @@ export const clearExpandedPanel = ({ commit }) => {
// All Data
+/**
+ * Fetch all dashboard data.
+ *
+ * @param {Object} store
+ * @returns A promise that resolves when the dashboard
+ * skeleton has been loaded.
+ */
export const fetchData = ({ dispatch }) => {
dispatch('fetchEnvironmentsData');
dispatch('fetchDashboard');
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 7c6cd19eb7b..4f90294ee3a 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -1,3 +1,4 @@
+import { pickBy } from 'lodash';
import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
import {
timeRangeParamNames,
@@ -28,7 +29,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
);
};
-/* eslint-disable @gitlab/require-i18n-strings */
/**
* Checks that element that triggered event is located on cluster health check dashboard
* @param {HTMLElement} element to check against
@@ -36,6 +36,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
*/
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
+/* eslint-disable @gitlab/require-i18n-strings */
/**
* Tracks snowplow event when user generates link to metric chart
* @param {String} chart link that will be sent as a property for the event
@@ -71,6 +72,7 @@ export const downloadCSVOptions = title => {
return { category, action, label: 'Chart title', property: title };
};
+/* eslint-enable @gitlab/require-i18n-strings */
/**
* Generate options for snowplow to track adding a new metric via the dashboard
@@ -133,6 +135,68 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => {
};
/**
+ * Locates a panel (and its corresponding group) given a (URL) search query. Returns
+ * it as payload for the store to set the right expandaded panel.
+ *
+ * Params used to locate a panel are:
+ * - group: Group identifier
+ * - title: Panel title
+ * - y_label: Panel y_label
+ *
+ * @param {Object} dashboard - Dashboard reference from the Vuex store
+ * @param {String} search - URL location search query
+ * @returns {Object} payload - Payload for expanded panel to be displayed
+ * @returns {String} payload.group - Group where panel is located
+ * @returns {Object} payload.panel - Dashboard panel (graphData) reference
+ * @throws Will throw an error if Panel cannot be located.
+ */
+export const expandedPanelPayloadFromUrl = (dashboard, search = window.location.search) => {
+ const params = queryToObject(search);
+
+ // Search for the panel if any of the search params is identified
+ if (params.group || params.title || params.y_label) {
+ const panelGroup = dashboard.panelGroups.find(({ group }) => params.group === group);
+ const panel = panelGroup.panels.find(
+ // eslint-disable-next-line babel/camelcase
+ ({ y_label, title }) => y_label === params.y_label && title === params.title,
+ );
+
+ if (!panel) {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Panel could no found by URL parameters.');
+ }
+ return { group: panelGroup.group, panel };
+ }
+ return null;
+};
+
+/**
+ * Convert panel information to a URL for the user to
+ * bookmark or share highlighting a specific panel.
+ *
+ * @param {String} dashboardPath - Dashboard path used as identifier
+ * @param {String} group - Group Identifier
+ * @param {?Object} panel - Panel object from the dashboard
+ * @param {?String} url - Base URL including current search params
+ * @returns Dashboard URL which expands a panel (chart)
+ */
+export const panelToUrl = (dashboardPath, group, panel, url = window.location.href) => {
+ if (!group || !panel) {
+ return null;
+ }
+ const params = pickBy(
+ {
+ dashboard: dashboardPath,
+ group,
+ title: panel.title,
+ y_label: panel.y_label,
+ },
+ value => value != null,
+ );
+ return mergeUrlParams(params, url);
+};
+
+/**
* Get the metric value from first data point.
* Currently only used for bar charts
*
diff --git a/app/assets/javascripts/snippets/components/snippet_description_view.vue b/app/assets/javascripts/snippets/components/snippet_description_view.vue
new file mode 100644
index 00000000000..72afcc30be6
--- /dev/null
+++ b/app/assets/javascripts/snippets/components/snippet_description_view.vue
@@ -0,0 +1,21 @@
+<script>
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
+
+export default {
+ components: {
+ MarkdownFieldView,
+ },
+ props: {
+ description: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+};
+</script>
+<template>
+ <markdown-field-view class="snippet-description" data-qa-selector="snippet_description_field">
+ <div class="md js-snippet-description" v-html="description"></div>
+ </markdown-field-view>
+</template>
diff --git a/app/assets/javascripts/snippets/components/snippet_title.vue b/app/assets/javascripts/snippets/components/snippet_title.vue
index 06484ad5110..5267c3748ca 100644
--- a/app/assets/javascripts/snippets/components/snippet_title.vue
+++ b/app/assets/javascripts/snippets/components/snippet_title.vue
@@ -1,11 +1,14 @@
<script>
-import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { GlSprintf } from '@gitlab/ui';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import SnippetDescription from './snippet_description_view.vue';
+
export default {
components: {
TimeAgoTooltip,
GlSprintf,
+ SnippetDescription,
},
props: {
snippet: {
@@ -20,13 +23,8 @@ export default {
<h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title">
{{ snippet.title }}
</h2>
- <div
- v-if="snippet.description"
- class="description"
- data-qa-selector="snippet_description_field"
- >
- <div class="md js-snippet-description" v-html="snippet.descriptionHtml"></div>
- </div>
+
+ <snippet-description v-if="snippet.description" :description="snippet.descriptionHtml" />
<small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text">
<gl-sprintf :message="__('Edited %{timeago}')">
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index 42db1935123..6df53311ef0 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -42,6 +42,10 @@ export default {
type: String,
required: false,
},
+ pipelineMustSucceed: {
+ type: Boolean,
+ required: false,
+ },
sourceBranchLink: {
type: String,
required: false,
@@ -60,7 +64,10 @@ export default {
return this.pipeline && Object.keys(this.pipeline).length > 0;
},
hasCIError() {
- return this.hasCi && !this.ciStatus;
+ return (this.hasCi && !this.ciStatus) || this.hasPipelineMustSucceedConflict;
+ },
+ hasPipelineMustSucceedConflict() {
+ return !this.hasCi && this.pipelineMustSucceed;
},
status() {
return this.pipeline.details && this.pipeline.details.status
@@ -76,9 +83,13 @@ export default {
return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0;
},
errorText() {
+ if (this.hasPipelineMustSucceedConflict) {
+ return s__('Pipeline|No pipeline has been run for this commit.');
+ }
+
return sprintf(
s__(
- 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}',
+ 'Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}.',
),
{
linkStart: `<a href="${this.troubleshootingDocsPath}">`,
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
index 46210d810bc..8fba0e2981f 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue
@@ -79,6 +79,7 @@ export default {
:pipeline-coverage-delta="mr.pipelineCoverageDelta"
:ci-status="mr.ciStatus"
:has-ci="mr.hasCI"
+ :pipeline-must-succeed="mr.onlyAllowMergeIfPipelineSucceeds"
:source-branch="branch"
:source-branch-link="branchLink"
:troubleshooting-docs-path="mr.troubleshootingDocsPath"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 360a75c3946..82be5eeb5ff 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -1,6 +1,6 @@
<script>
import { isEmpty } from 'lodash';
-import { GlIcon, GlDeprecatedButton } from '@gitlab/ui';
+import { GlIcon, GlDeprecatedButton, GlSprintf, GlLink } from '@gitlab/ui';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
@@ -26,6 +26,8 @@ export default {
CommitEdit,
CommitMessageDropdown,
GlIcon,
+ GlSprintf,
+ GlLink,
GlDeprecatedButton,
MergeImmediatelyConfirmationDialog: () =>
import(
@@ -56,7 +58,7 @@ export default {
status() {
const { pipeline, isPipelineFailed, hasCI, ciStatus } = this.mr;
- if (hasCI && !ciStatus) {
+ if ((hasCI && !ciStatus) || this.hasPipelineMustSucceedConflict) {
return 'failed';
} else if (this.isAutoMergeAvailable) {
return 'pending';
@@ -97,6 +99,9 @@ export default {
return __('Merge');
},
+ hasPipelineMustSucceedConflict() {
+ return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
},
@@ -343,9 +348,19 @@ export default {
/>
</template>
<template v-else>
- <span class="bold js-resolve-mr-widget-items-message">
- {{ mergeDisabledText }}
- </span>
+ <div class="bold js-resolve-mr-widget-items-message">
+ <gl-sprintf
+ v-if="hasPipelineMustSucceedConflict"
+ :message="pipelineMustSucceedConflictText"
+ >
+ <template #link="{ content }">
+ <gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ <gl-sprintf v-else :message="mergeDisabledText" />
+ </div>
</template>
</div>
</div>
diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
index 32a2b7b83f4..39fa5e465b8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
+++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js
@@ -1,6 +1,9 @@
import { __ } from '~/locale';
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
+export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __(
+ 'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}',
+);
export default {
computed: {
@@ -16,6 +19,9 @@ export default {
mergeDisabledText() {
return MERGE_DISABLED_TEXT;
},
+ pipelineMustSucceedConflictText() {
+ return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
+ },
autoMergeText() {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 8c45e613312..2396ceab6e8 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -104,8 +104,11 @@ export default {
shouldRenderMergeHelp() {
return stateMaps.statesToShowHelpWidget.indexOf(this.mr.state) > -1;
},
+ hasPipelineMustSucceedConflict() {
+ return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
+ },
shouldRenderPipelines() {
- return this.mr.hasCI;
+ return this.mr.hasCI || this.hasPipelineMustSucceedConflict;
},
shouldSuggestPipelines() {
return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath;
@@ -432,7 +435,9 @@ export default {
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
</div>
</div>
- <div v-if="shouldRenderMergeHelp" class="mr-widget-footer"><mr-widget-merge-help /></div>
+ <div v-if="shouldRenderMergeHelp" class="mr-widget-footer">
+ <mr-widget-merge-help />
+ </div>
</div>
<mr-widget-pipeline-container
v-if="shouldRenderMergedPipeline"
diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
index 5a985426abc..b08218732f6 100644
--- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
+++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js
@@ -161,6 +161,7 @@ export default class MergeRequestStore {
// Paths are set on the first load of the page and not auto-refreshed
this.squashBeforeMergeHelpPath = data.squash_before_merge_help_path;
this.troubleshootingDocsPath = data.troubleshooting_docs_path;
+ this.pipelineMustSucceedDocsPath = data.pipeline_must_succeed_docs_path;
this.mergeRequestBasicPath = data.merge_request_basic_path;
this.mergeRequestWidgetPath = data.merge_request_widget_path;
this.mergeRequestCachedWidgetPath = data.merge_request_cached_widget_path;
diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
index afbfb1e0ee2..52ce05f0d99 100644
--- a/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
+++ b/app/assets/javascripts/vue_shared/components/blob_viewers/rich_viewer.vue
@@ -1,8 +1,12 @@
<script>
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
import ViewerMixin from './mixins';
import { handleBlobRichViewer } from '~/blob/viewer';
export default {
+ components: {
+ MarkdownFieldView,
+ },
mixins: [ViewerMixin],
mounted() {
handleBlobRichViewer(this.$refs.content, this.type);
@@ -10,5 +14,5 @@ export default {
};
</script>
<template>
- <div ref="content" v-html="content"></div>
+ <markdown-field-view ref="content" v-html="content" />
</template>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field_view.vue b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue
new file mode 100644
index 00000000000..d77123371f2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/field_view.vue
@@ -0,0 +1,19 @@
+<script>
+import $ from 'jquery';
+import '~/behaviors/markdown/render_gfm';
+
+export default {
+ mounted() {
+ this.renderGFM();
+ },
+ methods: {
+ renderGFM() {
+ $(this.$el).renderGFM();
+ },
+ },
+};
+</script>
+
+<template>
+ <div><slot></slot></div>
+</template>
diff --git a/app/assets/stylesheets/page_bundles/themes/_dark.scss b/app/assets/stylesheets/page_bundles/themes/_dark.scss
index 634f18ee1bd..30822eb7de4 100644
--- a/app/assets/stylesheets/page_bundles/themes/_dark.scss
+++ b/app/assets/stylesheets/page_bundles/themes/_dark.scss
@@ -94,7 +94,7 @@
}
.ide-pipeline svg {
- --svg-status-bg: transparent;
+ --svg-status-bg: $background;
}
.multi-file-tab-close:hover {
diff --git a/app/assets/stylesheets/pages/alerts_list.scss b/app/assets/stylesheets/pages/alerts_list.scss
index 0d0db0ea6fe..5974f97b728 100644
--- a/app/assets/stylesheets/pages/alerts_list.scss
+++ b/app/assets/stylesheets/pages/alerts_list.scss
@@ -74,4 +74,14 @@
}
}
}
+
+ .gl-tab-nav-item {
+ color: $gl-gray-600;
+
+ > .gl-tab-counter-badge {
+ color: inherit;
+ @include gl-font-sm;
+ background-color: $white-normal;
+ }
+ }
}
diff --git a/app/controllers/projects/alert_management_controller.rb b/app/controllers/projects/alert_management_controller.rb
index fd85b4346c0..9be8a89fc02 100644
--- a/app/controllers/projects/alert_management_controller.rb
+++ b/app/controllers/projects/alert_management_controller.rb
@@ -3,6 +3,9 @@
class Projects::AlertManagementController < Projects::ApplicationController
before_action :ensure_list_feature_enabled, only: :index
before_action :ensure_detail_feature_enabled, only: :details
+ before_action do
+ push_frontend_feature_flag(:alert_list_status_filtering_enabled)
+ end
def index
end
diff --git a/app/graphql/types/alert_management/status_enum.rb b/app/graphql/types/alert_management/status_enum.rb
index f55e954d5d4..4ff6c4a9505 100644
--- a/app/graphql/types/alert_management/status_enum.rb
+++ b/app/graphql/types/alert_management/status_enum.rb
@@ -6,8 +6,8 @@ module Types
graphql_name 'AlertManagementStatus'
description 'Alert status values'
- ::AlertManagement::Alert.statuses.keys.each do |status|
- value status.upcase, value: status, description: "#{status.titleize} status"
+ ::AlertManagement::Alert::STATUSES.each do |name, value|
+ value name.upcase, value: value, description: "#{name.to_s.titleize} status"
end
end
end
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
index 6522d52690d..6bbdc1645ca 100644
--- a/app/models/alert_management/alert.rb
+++ b/app/models/alert_management/alert.rb
@@ -6,6 +6,20 @@ module AlertManagement
include ShaAttribute
include Sortable
+ STATUSES = {
+ triggered: 0,
+ acknowledged: 1,
+ resolved: 2,
+ ignored: 3
+ }.freeze
+
+ STATUS_EVENTS = {
+ triggered: :trigger,
+ acknowledged: :acknowledge,
+ resolved: :resolve,
+ ignored: :ignore
+ }.freeze
+
belongs_to :project
belongs_to :issue, optional: true
has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) }
@@ -37,14 +51,49 @@ module AlertManagement
unknown: 5
}
- enum status: {
- triggered: 0,
- acknowledged: 1,
- resolved: 2,
- ignored: 3
- }
+ state_machine :status, initial: :triggered do
+ state :triggered, value: STATUSES[:triggered]
+
+ state :acknowledged, value: STATUSES[:acknowledged]
+
+ state :resolved, value: STATUSES[:resolved] do
+ validates :ended_at, presence: true
+ end
+
+ state :ignored, value: STATUSES[:ignored]
+
+ state :triggered, :acknowledged, :ignored do
+ validates :ended_at, absence: true
+ end
+
+ event :trigger do
+ transition any => :triggered
+ end
+
+ event :acknowledge do
+ transition any => :acknowledged
+ end
+
+ event :resolve do
+ transition any => :resolved
+ end
+
+ event :ignore do
+ transition any => :ignored
+ end
+
+ before_transition to: [:triggered, :acknowledged, :ignored] do |alert, _transition|
+ alert.ended_at = nil
+ end
+
+ before_transition to: :resolved do |alert, transition|
+ ended_at = transition.args.first
+ alert.ended_at = ended_at || Time.current
+ end
+ end
scope :for_iid, -> (iid) { where(iid: iid) }
+ scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) }
scope :order_start_time, -> (sort_order) { order(started_at: sort_order) }
scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) }
@@ -69,14 +118,6 @@ module AlertManagement
end
end
- def fingerprint=(value)
- if value.blank?
- super(nil)
- else
- super(Digest::SHA1.hexdigest(value.to_s))
- end
- end
-
def details
details_payload = payload.except(*attributes.keys)
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index ea3e00d9cd3..f793bd3d76f 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -385,37 +385,12 @@ class MergeRequestDiff < ApplicationRecord
end
end
- # Carrierwave defines `write_uploader` dynamically on this class, so `super`
- # does not work. Alias the carrierwave method so we can call it when needed
- alias_method :carrierwave_write_uploader, :write_uploader
-
- # The `external_diff`, `external_diff_store`, and `stored_externally`
- # columns were introduced in GitLab 11.8, but some background migration specs
- # use factories that rely on current code with an old schema. Without these
- # `has_attribute?` guards, they fail with a `MissingAttributeError`.
- #
- # For more details, see: https://gitlab.com/gitlab-org/gitlab-foss/issues/44990
-
- def write_uploader(column, identifier)
- carrierwave_write_uploader(column, identifier) if has_attribute?(column)
- end
-
def update_external_diff_store
- return unless has_attribute?(:external_diff_store)
return unless saved_change_to_external_diff? || saved_change_to_stored_externally?
update_column(:external_diff_store, external_diff.object_store)
end
- def saved_change_to_external_diff?
- super if has_attribute?(:external_diff)
- end
-
- def stored_externally
- super if has_attribute?(:stored_externally)
- end
- alias_method :stored_externally?, :stored_externally
-
# If enabled, yields the external file containing the diff. Otherwise, yields
# nil. This method is not thread-safe, but it *is* re-entrant, which allows
# multiple merge_request_diff_files to load their data efficiently
@@ -577,7 +552,6 @@ class MergeRequestDiff < ApplicationRecord
end
def use_external_diff?
- return false unless has_attribute?(:external_diff)
return false unless Gitlab.config.external_diffs.enabled
case Gitlab.config.external_diffs.when
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 98715a8c67c..e2d1a433935 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -175,6 +175,10 @@ class Namespace < ApplicationRecord
kind == 'user'
end
+ def group?
+ type == 'Group'
+ end
+
def find_fork_of(project)
return unless project.fork_network
diff --git a/app/services/alert_management/process_prometheus_alert_service.rb b/app/services/alert_management/process_prometheus_alert_service.rb
new file mode 100644
index 00000000000..af28f1354b3
--- /dev/null
+++ b/app/services/alert_management/process_prometheus_alert_service.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class ProcessPrometheusAlertService < BaseService
+ include Gitlab::Utils::StrongMemoize
+
+ def execute
+ return bad_request unless parsed_alert.valid?
+
+ process_alert_management_alert
+
+ ServiceResponse.success
+ end
+
+ private
+
+ delegate :firing?, :resolved?, :gitlab_fingerprint, :ends_at, to: :parsed_alert
+
+ def parsed_alert
+ strong_memoize(:parsed_alert) do
+ Gitlab::Alerting::Alert.new(project: project, payload: params)
+ end
+ end
+
+ def process_alert_management_alert
+ process_firing_alert_management_alert if firing?
+ process_resolved_alert_management_alert if resolved?
+ end
+
+ def process_firing_alert_management_alert
+ if am_alert.present?
+ reset_alert_management_alert_status
+ else
+ create_alert_management_alert
+ end
+ end
+
+ def reset_alert_management_alert_status
+ return if am_alert.trigger
+
+ logger.warn(
+ message: 'Unable to update AlertManagement::Alert status to triggered',
+ project_id: project.id,
+ alert_id: am_alert.id
+ )
+ end
+
+ def create_alert_management_alert
+ am_alert = AlertManagement::Alert.new(am_alert_params.merge(ended_at: nil))
+ return if am_alert.save
+
+ logger.warn(
+ message: 'Unable to create AlertManagement::Alert',
+ project_id: project.id,
+ alert_errors: am_alert.errors.messages
+ )
+ end
+
+ def am_alert_params
+ Gitlab::AlertManagement::AlertParams.from_prometheus_alert(project: project, parsed_alert: parsed_alert)
+ end
+
+ def process_resolved_alert_management_alert
+ return if am_alert.blank?
+ return if am_alert.resolve(ends_at)
+
+ logger.warn(
+ message: 'Unable to update AlertManagement::Alert status to resolved',
+ project_id: project.id,
+ alert_id: am_alert.id
+ )
+ end
+
+ def logger
+ @logger ||= Gitlab::AppLogger
+ end
+
+ def am_alert
+ @am_alert ||= AlertManagement::Alert.for_fingerprint(project, gitlab_fingerprint).first
+ end
+
+ def bad_request
+ ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
+ end
+ end
+end
diff --git a/app/services/alert_management/update_alert_status_service.rb b/app/services/alert_management/update_alert_status_service.rb
index 73ee654874f..8e0c3e38b8b 100644
--- a/app/services/alert_management/update_alert_status_service.rb
+++ b/app/services/alert_management/update_alert_status_service.rb
@@ -8,9 +8,9 @@ module AlertManagement
end
def execute
- return error('Invalid status') unless AlertManagement::Alert.statuses.key?(status.to_s)
+ return error('Invalid status') unless AlertManagement::Alert::STATUSES.key?(status.to_sym)
- alert.status = status
+ alert.status_event = AlertManagement::Alert::STATUS_EVENTS[status.to_sym]
if alert.save
success
diff --git a/app/services/concerns/git/logger.rb b/app/services/concerns/git/logger.rb
deleted file mode 100644
index 7c036212e66..00000000000
--- a/app/services/concerns/git/logger.rb
+++ /dev/null
@@ -1,10 +0,0 @@
-# frozen_string_literal: true
-
-module Git
- module Logger
- def log_error(message, save_message_on_model: false)
- Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}")
- merge_request.update(merge_error: message) if save_message_on_model
- end
- end
-end
diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb
index 5365c09eb25..b250c2d7d21 100644
--- a/app/services/merge_requests/base_service.rb
+++ b/app/services/merge_requests/base_service.rb
@@ -114,6 +114,32 @@ module MergeRequests
yield merge_request
end
end
+
+ def log_error(exception:, message:, save_message_on_model: false)
+ reference = merge_request.to_reference(full: true)
+ data = {
+ class: self.class.name,
+ message: message,
+ merge_request_id: merge_request.id,
+ merge_request: reference,
+ save_message_on_model: save_message_on_model
+ }
+
+ if exception
+ Gitlab::ErrorTracking.with_context(current_user) do
+ Gitlab::ErrorTracking.track_exception(exception, data)
+ end
+
+ data[:"exception.message"] = exception.message
+ end
+
+ # TODO: Deprecate Gitlab::GitLogger since ErrorTracking should suffice:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/216379
+ data[:message] = "#{self.class.name} error (#{reference}): #{message}"
+ Gitlab::GitLogger.error(data)
+
+ merge_request.update(merge_error: message) if save_message_on_model
+ end
end
end
diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb
index bc1e97088af..87808a21a15 100644
--- a/app/services/merge_requests/rebase_service.rb
+++ b/app/services/merge_requests/rebase_service.rb
@@ -2,8 +2,6 @@
module MergeRequests
class RebaseService < MergeRequests::BaseService
- include Git::Logger
-
REBASE_ERROR = 'Rebase failed. Please rebase locally'
attr_reader :merge_request
@@ -22,7 +20,7 @@ module MergeRequests
def rebase
# Ensure Gitaly isn't already running a rebase
if source_project.repository.rebase_in_progress?(merge_request.id)
- log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
+ log_error(exception: nil, message: 'Rebase task canceled: Another rebase is already in progress', save_message_on_model: true)
return false
end
@@ -30,8 +28,8 @@ module MergeRequests
true
rescue => e
- log_error(REBASE_ERROR, save_message_on_model: true)
- log_error(e.message)
+ log_error(exception: e, message: REBASE_ERROR, save_message_on_model: true)
+
false
ensure
merge_request.update_column(:rebase_jid, nil)
diff --git a/app/services/merge_requests/squash_service.rb b/app/services/merge_requests/squash_service.rb
index d25997c925e..4b04d42b48e 100644
--- a/app/services/merge_requests/squash_service.rb
+++ b/app/services/merge_requests/squash_service.rb
@@ -2,7 +2,7 @@
module MergeRequests
class SquashService < MergeRequests::BaseService
- include Git::Logger
+ SquashInProgressError = Class.new(RuntimeError)
def execute
# If performing a squash would result in no change, then
@@ -11,11 +11,13 @@ module MergeRequests
return success(squash_sha: merge_request.diff_head_sha)
end
- if merge_request.squash_in_progress?
+ if squash_in_progress?
return error(s_('MergeRequests|Squash task canceled: another squash is already in progress.'))
end
squash! || error(s_('MergeRequests|Failed to squash. Should be done manually.'))
+ rescue SquashInProgressError
+ error(s_('MergeRequests|An error occurred while checking whether another squash is in progress.'))
end
private
@@ -25,11 +27,19 @@ module MergeRequests
success(squash_sha: squash_sha)
rescue => e
- log_error("Failed to squash merge request #{merge_request.to_reference(full: true)}:")
- log_error(e.message)
+ log_error(exception: e, message: 'Failed to squash merge request')
+
false
end
+ def squash_in_progress?
+ merge_request.squash_in_progress?
+ rescue => e
+ log_error(exception: e, message: 'Failed to check squash in progress')
+
+ raise SquashInProgressError, e.message
+ end
+
def repository
target_project.repository
end
diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb
index 6ebc061c2e3..c1793d2a2d6 100644
--- a/app/services/projects/prometheus/alerts/notify_service.rb
+++ b/app/services/projects/prometheus/alerts/notify_service.rb
@@ -12,6 +12,7 @@ module Projects
return unprocessable_entity unless valid_version?
return unauthorized unless valid_alert_manager_token?(token)
+ process_prometheus_alerts
persist_events
send_alert_email if send_email?
process_incident_issues if process_issues?
@@ -115,6 +116,16 @@ module Projects
end
end
+ def process_prometheus_alerts
+ return unless Feature.enabled?(:alert_management_minimal, project)
+
+ alerts.each do |alert|
+ AlertManagement::ProcessPrometheusAlertService
+ .new(project, nil, alert.to_h)
+ .execute
+ end
+ end
+
def persist_events
CreateEventsService.new(project, nil, params).execute
end
diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml
index 1853d40c2e4..6aba5c98d52 100644
--- a/app/views/projects/merge_requests/_widget.html.haml
+++ b/app/views/projects/merge_requests/_widget.html.haml
@@ -8,6 +8,7 @@
window.gl.mrWidgetData.squash_before_merge_help_path = '#{help_page_path("user/project/merge_requests/squash_and_merge")}';
window.gl.mrWidgetData.troubleshooting_docs_path = '#{help_page_path('user/project/merge_requests/reviewing_and_managing_merge_requests.md', anchor: 'troubleshooting')}';
+ window.gl.mrWidgetData.pipeline_must_succeed_docs_path = '#{help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds.md', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds')}';
window.gl.mrWidgetData.security_approvals_help_page_path = '#{help_page_path('user/application_security/index.html', anchor: 'security-approvals-in-merge-requests-ultimate')}';
window.gl.mrWidgetData.eligible_approvers_docs_path = '#{help_page_path('user/project/merge_requests/merge_request_approvals', anchor: 'eligible-approvers')}';
window.gl.mrWidgetData.pipelines_empty_svg_path = '#{image_path('illustrations/pipelines_empty.svg')}';
diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml
index 6809d818717..7155107b586 100644
--- a/app/workers/all_queues.yml
+++ b/app/workers/all_queues.yml
@@ -18,35 +18,35 @@
:weight: 3
:idempotent:
- :name: chaos:chaos_cpu_spin
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_db_spin
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_kill
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_leak_mem
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 2
:idempotent:
- :name: chaos:chaos_sleep
- :feature_category: :chaos_engineering
+ :feature_category: :not_owned
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
diff --git a/app/workers/concerns/chaos_queue.rb b/app/workers/concerns/chaos_queue.rb
index c5db10491f2..a9c557f0175 100644
--- a/app/workers/concerns/chaos_queue.rb
+++ b/app/workers/concerns/chaos_queue.rb
@@ -5,6 +5,6 @@ module ChaosQueue
included do
queue_namespace :chaos
- feature_category :chaos_engineering
+ feature_category_not_owned!
end
end
diff --git a/changelogs/unreleased/118609-design-comment-edit-comment-text.yml b/changelogs/unreleased/118609-design-comment-edit-comment-text.yml
new file mode 100644
index 00000000000..d5fdc2ca274
--- /dev/null
+++ b/changelogs/unreleased/118609-design-comment-edit-comment-text.yml
@@ -0,0 +1,5 @@
+---
+title: 'Resolve Design Comment: Edit Comment text'
+merge_request: 30479
+author:
+type: added
diff --git a/changelogs/unreleased/197344-use-cookies-with-metadata.yml b/changelogs/unreleased/197344-use-cookies-with-metadata.yml
new file mode 100644
index 00000000000..c07927ee6ad
--- /dev/null
+++ b/changelogs/unreleased/197344-use-cookies-with-metadata.yml
@@ -0,0 +1,5 @@
+---
+title: Use cookies with metadata to prevent reuse as another cookie
+merge_request: 31311
+author:
+type: other
diff --git a/changelogs/unreleased/212279-webide-vue-files.yml b/changelogs/unreleased/212279-webide-vue-files.yml
new file mode 100644
index 00000000000..957c1642c0c
--- /dev/null
+++ b/changelogs/unreleased/212279-webide-vue-files.yml
@@ -0,0 +1,5 @@
+---
+title: 'Web IDE: Introduce syntax highlighting for .vue files.'
+merge_request: 30986
+author:
+type: added
diff --git a/changelogs/unreleased/215472-single-chart-from-url.yml b/changelogs/unreleased/215472-single-chart-from-url.yml
new file mode 100644
index 00000000000..f5fef9e76c3
--- /dev/null
+++ b/changelogs/unreleased/215472-single-chart-from-url.yml
@@ -0,0 +1,5 @@
+---
+title: Display expanded dashboard from a panel's "Link to chart" URL
+merge_request: 30476
+author:
+type: added
diff --git a/changelogs/unreleased/216031-show-accurate-error-message-when-pipelines-disabled-mr-must-succee.yml b/changelogs/unreleased/216031-show-accurate-error-message-when-pipelines-disabled-mr-must-succee.yml
new file mode 100644
index 00000000000..c7868e90106
--- /dev/null
+++ b/changelogs/unreleased/216031-show-accurate-error-message-when-pipelines-disabled-mr-must-succee.yml
@@ -0,0 +1,7 @@
+---
+title:
+ Add clear explanation to the MR widget when no CI is available and Pipeline
+ must succeed option is activated
+merge_request: 31112
+author:
+type: changed
diff --git a/changelogs/unreleased/cleanup_user_highest_roles_background_migration.yml b/changelogs/unreleased/cleanup_user_highest_roles_background_migration.yml
new file mode 100644
index 00000000000..fa7af7c7f34
--- /dev/null
+++ b/changelogs/unreleased/cleanup_user_highest_roles_background_migration.yml
@@ -0,0 +1,5 @@
+---
+title: Cleanup background migration for populating user_highest_roles table
+merge_request: 31218
+author:
+type: other
diff --git a/changelogs/unreleased/sh-fix-squash-error-handling.yml b/changelogs/unreleased/sh-fix-squash-error-handling.yml
new file mode 100644
index 00000000000..d011d424a27
--- /dev/null
+++ b/changelogs/unreleased/sh-fix-squash-error-handling.yml
@@ -0,0 +1,5 @@
+---
+title: Improve error handling of squash and rebase
+merge_request: 23740
+author:
+type: other
diff --git a/config/feature_categories.yml b/config/feature_categories.yml
index 6033a128284..010d3d14fcb 100644
--- a/config/feature_categories.yml
+++ b/config/feature_categories.yml
@@ -19,7 +19,6 @@
- backup_restore
- behavior_analytics
- billing
-- chaos_engineering
- chatops
- cloud_native_installation
- cluster_cost_optimization
diff --git a/config/initializers/cookies_serializer.rb b/config/initializers/cookies_serializer.rb
index a04d5044f4e..fa1736dfea6 100644
--- a/config/initializers/cookies_serializer.rb
+++ b/config/initializers/cookies_serializer.rb
@@ -1,4 +1,4 @@
# Be sure to restart your server when you modify this file.
-Rails.application.config.action_dispatch.use_cookies_with_metadata = false
+Rails.application.config.action_dispatch.use_cookies_with_metadata = true
Rails.application.config.action_dispatch.cookies_serializer = :hybrid
diff --git a/db/post_migrate/20200506125731_cleanup_user_highest_roles_population.rb b/db/post_migrate/20200506125731_cleanup_user_highest_roles_population.rb
new file mode 100644
index 00000000000..5e613228c56
--- /dev/null
+++ b/db/post_migrate/20200506125731_cleanup_user_highest_roles_population.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class CleanupUserHighestRolesPopulation < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ INDEX_NAME = 'index_for_migrating_user_highest_roles_table'
+
+ disable_ddl_transaction!
+
+ def up
+ Gitlab::BackgroundMigration.steal('PopulateUserHighestRolesTable')
+
+ remove_concurrent_index(:users, :id, name: INDEX_NAME)
+ end
+
+ def down
+ add_concurrent_index(:users,
+ :id,
+ where: "state = 'active' AND user_type IS NULL AND bot_type IS NULL AND ghost IS NOT TRUE",
+ name: INDEX_NAME)
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index e450a99eace..df1eac0303a 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -9556,8 +9556,6 @@ CREATE UNIQUE INDEX index_feature_gates_on_feature_key_and_key_and_value ON publ
CREATE UNIQUE INDEX index_features_on_key ON public.features USING btree (key);
-CREATE INDEX index_for_migrating_user_highest_roles_table ON public.users USING btree (id) WHERE (((state)::text = 'active'::text) AND (user_type IS NULL) AND (bot_type IS NULL) AND (ghost IS NOT TRUE));
-
CREATE INDEX index_for_resource_group ON public.ci_builds USING btree (resource_group_id, id) WHERE (resource_group_id IS NOT NULL);
CREATE INDEX index_for_status_per_branch_per_project ON public.merge_trains USING btree (target_project_id, target_branch, status);
@@ -13709,5 +13707,6 @@ COPY "schema_migrations" (version) FROM STDIN;
20200429181335
20200429181955
20200429182245
+20200506125731
\.
diff --git a/doc/development/documentation/styleguide.md b/doc/development/documentation/styleguide.md
index 630a1845002..ab57ff686b5 100644
--- a/doc/development/documentation/styleguide.md
+++ b/doc/development/documentation/styleguide.md
@@ -248,12 +248,6 @@ GitLab documentation should be clear and easy to understand.
- Be clear, concise, and stick to the goal of the documentation.
- Write in US English with US grammar.
- Use inclusive language.
-- Avoid jargon.
-- Avoid uncommon words.
-- Don't write in the first person singular.
- - Instead of "I" or "me," use "we," "you," "us," or "one."
- - When possible, stay user focused by writing in the second person ("you" or the imperative).
-- Don't overuse "that". In many cases, you can remove "that" from a sentence and improve readability.
### Point of view
@@ -287,25 +281,52 @@ because it’s friendly and easy to understand.
Some features are also objects. For example, "GitLab's Merge Requests support X" and
"Create a new merge request for Z."
+### Language to avoid
+
+When creating documentation, limit or avoid the use of the following verb
+tenses, words, and phrases:
+
+- Avoid jargon.
+- Avoid uncommon words.
+- Don't write in the first person singular.
+ - Instead of "I" or "me," use "we," "you," "us," or "one."
+ - When possible, stay user focused by writing in the second person ("you" or
+ the imperative).
+- Don't overuse "that". In many cases, you can remove "that" from a sentence
+ and improve readability.
- Avoid use of the future tense:
- - Instead of "after you execute this command, GitLab will display the result", use "after you execute this command, GitLab displays the result".
- - Only use the future tense to convey when the action or result will actually occur at a future time.
-- Do not use slashes to clump different words together or as a replacement for the word "or":
- - Instead of "and/or," consider using "or," or use another sensible construction.
- - Other examples include "clone/fetch," author/assignee," and "namespace/repository name." Break apart any such instances in an appropriate way.
- - Exceptions to this rule include commonly accepted technical terms such as CI/CD, TCP/IP, and so on.
-- Do not use "may" and "might" interchangeably:
- - Use "might" to indicate the probability of something occurring. "If you skip this step, the import process might fail."
- - Use "may" to indicate giving permission for someone to do something, or consider using "can" instead. "You may select either option on this screen." Or, "you can select either option on this screen."
+ - Instead of "after you execute this command, GitLab will display the
+ result", use "after you execute this command, GitLab displays the result".
+ - Only use the future tense to convey when the action or result will actually
+ occur at a future time.
+- Don't use slashes to clump different words together or as a replacement for
+ the word "or":
+ - Instead of "and/or," consider using "or," or use another sensible
+ construction.
+ - Other examples include "clone/fetch," author/assignee," and
+ "namespace/repository name." Break apart any such instances in an
+ appropriate way.
+ - Exceptions to this rule include commonly accepted technical terms, such as
+ CI/CD and TCP/IP.
- We discourage use of Latin abbreviations, such as "e.g.," "i.e.," or "etc.,"
-as even native users of English might misunderstand them.
+ as even native users of English might misunderstand them.
- Instead of "i.e.," use "that is."
- Instead of "e.g.," use "for example," "such as," "for instance," or "like."
- - Instead of "etc.," either use "and so on" or consider editing it out, since it can be vague.
-- Avoid using the word *Currently* when talking about the product or its
+ - Instead of "etc.," either use "and so on" or consider editing it out, since
+ it can be vague.
+- Avoid using the word *currently* when talking about the product or its
features. The documentation describes the product as it is, and not as it
will be at some indeterminate point in the future.
+### Word usage clarifications
+
+- Don't use "may" and "might" interchangeably:
+ - Use "might" to indicate the probability of something occurring. "If you
+ skip this step, the import process might fail."
+ - Use "may" to indicate giving permission for someone to do something, or
+ consider using "can" instead. "You may select either option on this
+ screen." Or, "You can select either option on this screen."
+
### Contractions
- Use common contractions when it helps create a friendly and informal tone, especially in tutorials, instructional documentation, and [UIs](https://design.gitlab.com/content/punctuation/#contractions).
diff --git a/lib/gitlab/alert_management/alert_params.rb b/lib/gitlab/alert_management/alert_params.rb
index 014eba6326d..876eff6d91e 100644
--- a/lib/gitlab/alert_management/alert_params.rb
+++ b/lib/gitlab/alert_management/alert_params.rb
@@ -3,6 +3,10 @@
module Gitlab
module AlertManagement
class AlertParams
+ MONITORING_TOOLS = {
+ prometheus: 'Prometheus'
+ }.freeze
+
def self.from_generic_alert(project:, payload:)
parsed_payload = Gitlab::Alerting::NotificationPayloadParser.call(payload).with_indifferent_access
annotations = parsed_payload[:annotations]
@@ -18,6 +22,19 @@ module Gitlab
started_at: parsed_payload['startsAt']
}
end
+
+ def self.from_prometheus_alert(project:, parsed_alert:)
+ {
+ project_id: project.id,
+ title: parsed_alert.title,
+ description: parsed_alert.description,
+ monitoring_tool: MONITORING_TOOLS[:prometheus],
+ payload: parsed_alert.payload,
+ started_at: parsed_alert.starts_at,
+ ended_at: parsed_alert.ends_at,
+ fingerprint: parsed_alert.gitlab_fingerprint
+ }
+ end
end
end
end
diff --git a/lib/gitlab/alerting/alert.rb b/lib/gitlab/alerting/alert.rb
index 7d97bd1bb52..d859ca89418 100644
--- a/lib/gitlab/alerting/alert.rb
+++ b/lib/gitlab/alerting/alert.rb
@@ -105,6 +105,10 @@ module Gitlab
metric_id.present?
end
+ def gitlab_fingerprint
+ Digest::SHA1.hexdigest(plain_gitlab_fingerprint)
+ end
+
def valid?
payload.respond_to?(:dig) && project && title && starts_at
end
@@ -115,6 +119,14 @@ module Gitlab
private
+ def plain_gitlab_fingerprint
+ if gitlab_managed?
+ [metric_id, starts_at].join('/')
+ else # self managed
+ [starts_at, title, full_query].join('/')
+ end
+ end
+
def parse_environment_from_payload
environment_name = payload&.dig('labels', 'gitlab_environment_name')
diff --git a/lib/gitlab/metrics/samplers/ruby_sampler.rb b/lib/gitlab/metrics/samplers/ruby_sampler.rb
index c38769f39a9..0ca266f2e0c 100644
--- a/lib/gitlab/metrics/samplers/ruby_sampler.rb
+++ b/lib/gitlab/metrics/samplers/ruby_sampler.rb
@@ -34,14 +34,16 @@ module Gitlab
def init_metrics
metrics = {
- file_descriptors: ::Gitlab::Metrics.gauge(with_prefix(:file, :descriptors), 'File descriptors used', labels),
- memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:memory, :bytes), 'Memory used', labels),
- process_cpu_seconds_total: ::Gitlab::Metrics.gauge(with_prefix(:process, :cpu_seconds_total), 'Process CPU seconds total'),
- process_max_fds: ::Gitlab::Metrics.gauge(with_prefix(:process, :max_fds), 'Process max fds'),
- process_resident_memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:process, :resident_memory_bytes), 'Memory used', labels),
- process_start_time_seconds: ::Gitlab::Metrics.gauge(with_prefix(:process, :start_time_seconds), 'Process start time seconds'),
- sampler_duration: ::Gitlab::Metrics.counter(with_prefix(:sampler, :duration_seconds_total), 'Sampler time', labels),
- gc_duration_seconds: ::Gitlab::Metrics.histogram(with_prefix(:gc, :duration_seconds), 'GC time', labels, GC_REPORT_BUCKETS)
+ file_descriptors: ::Gitlab::Metrics.gauge(with_prefix(:file, :descriptors), 'File descriptors used', labels),
+ memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:memory, :bytes), 'Memory used (RSS)', labels),
+ process_cpu_seconds_total: ::Gitlab::Metrics.gauge(with_prefix(:process, :cpu_seconds_total), 'Process CPU seconds total'),
+ process_max_fds: ::Gitlab::Metrics.gauge(with_prefix(:process, :max_fds), 'Process max fds'),
+ process_resident_memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:process, :resident_memory_bytes), 'Memory used (RSS)', labels),
+ process_unique_memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:process, :unique_memory_bytes), 'Memory used (USS)', labels),
+ process_proportional_memory_bytes: ::Gitlab::Metrics.gauge(with_prefix(:process, :proportional_memory_bytes), 'Memory used (PSS)', labels),
+ process_start_time_seconds: ::Gitlab::Metrics.gauge(with_prefix(:process, :start_time_seconds), 'Process start time seconds'),
+ sampler_duration: ::Gitlab::Metrics.counter(with_prefix(:sampler, :duration_seconds_total), 'Sampler time', labels),
+ gc_duration_seconds: ::Gitlab::Metrics.histogram(with_prefix(:gc, :duration_seconds), 'GC time', labels, GC_REPORT_BUCKETS)
}
GC.stat.keys.each do |key|
@@ -85,10 +87,15 @@ module Gitlab
end
def set_memory_usage_metrics
- memory_usage = System.memory_usage
-
- metrics[:memory_bytes].set(labels, memory_usage)
- metrics[:process_resident_memory_bytes].set(labels, memory_usage)
+ memory_rss = System.memory_usage
+ metrics[:memory_bytes].set(labels, memory_rss)
+ metrics[:process_resident_memory_bytes].set(labels, memory_rss)
+
+ if Gitlab::Utils.to_boolean(ENV['enable_memory_uss_pss'])
+ memory_uss_pss = System.memory_usage_uss_pss
+ metrics[:process_unique_memory_bytes].set(labels, memory_uss_pss[:uss])
+ metrics[:process_proportional_memory_bytes].set(labels, memory_uss_pss[:pss])
+ end
end
end
end
diff --git a/lib/gitlab/metrics/system.rb b/lib/gitlab/metrics/system.rb
index 2a61b3de405..d01b6bc5b50 100644
--- a/lib/gitlab/metrics/system.rb
+++ b/lib/gitlab/metrics/system.rb
@@ -7,47 +7,37 @@ module Gitlab
# This module relies on the /proc filesystem being available. If /proc is
# not available the methods of this module will be stubbed.
module System
- if File.exist?('/proc')
- # Returns the current process' memory usage in bytes.
- def self.memory_usage
- mem = 0
- match = File.read('/proc/self/status').match(/VmRSS:\s+(\d+)/)
-
- if match && match[1]
- mem = match[1].to_f * 1024
- end
-
- mem
- end
-
- def self.file_descriptor_count
- Dir.glob('/proc/self/fd/*').length
- end
-
- def self.max_open_file_descriptors
- match = File.read('/proc/self/limits').match(/Max open files\s*(\d+)/)
-
- return unless match && match[1]
+ PROC_STATUS_PATH = '/proc/self/status'
+ PROC_SMAPS_ROLLUP_PATH = '/proc/self/smaps_rollup'
+ PROC_LIMITS_PATH = '/proc/self/limits'
+ PROC_FD_GLOB = '/proc/self/fd/*'
+
+ PRIVATE_PAGES_PATTERN = /^(Private_Clean|Private_Dirty|Private_Hugetlb):\s+(?<value>\d+)/.freeze
+ PSS_PATTERN = /^Pss:\s+(?<value>\d+)/.freeze
+ RSS_PATTERN = /VmRSS:\s+(?<value>\d+)/.freeze
+ MAX_OPEN_FILES_PATTERN = /Max open files\s*(?<value>\d+)/.freeze
+
+ # Returns the current process' RSS (resident set size) in bytes.
+ def self.memory_usage
+ sum_matches(PROC_STATUS_PATH, rss: RSS_PATTERN)[:rss].kilobytes
+ end
- match[1].to_i
- end
- else
- def self.memory_usage
- 0.0
- end
+ # Returns the current process' USS/PSS (unique/proportional set size) in bytes.
+ def self.memory_usage_uss_pss
+ sum_matches(PROC_SMAPS_ROLLUP_PATH, uss: PRIVATE_PAGES_PATTERN, pss: PSS_PATTERN)
+ .transform_values(&:kilobytes)
+ end
- def self.file_descriptor_count
- 0
- end
+ def self.file_descriptor_count
+ Dir.glob(PROC_FD_GLOB).length
+ end
- def self.max_open_file_descriptors
- 0
- end
+ def self.max_open_file_descriptors
+ sum_matches(PROC_LIMITS_PATH, max_fds: MAX_OPEN_FILES_PATTERN)[:max_fds]
end
def self.cpu_time
- Process
- .clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :float_second)
+ Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID, :float_second)
end
# Returns the current real time in a given precision.
@@ -78,6 +68,27 @@ module Gitlab
end_time - start_time
end
+
+ # Given a path to a file in /proc and a hash of (metric, pattern) pairs,
+ # sums up all values found for those patterns under the respective metric.
+ def self.sum_matches(proc_file, **patterns)
+ results = patterns.transform_values { 0 }
+
+ begin
+ File.foreach(proc_file) do |line|
+ patterns.each do |metric, pattern|
+ match = line.match(pattern)
+ value = match&.named_captures&.fetch('value', 0)
+ results[metric] += value.to_i
+ end
+ end
+ rescue Errno::ENOENT
+ # This means the procfile we're reading from did not exist;
+ # this is safe to ignore, since we initialize each metric to 0
+ end
+
+ results
+ end
end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index b504a5ce6bc..abb751000b1 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1712,6 +1712,9 @@ msgstr ""
msgid "AlertManagement|Alerts"
msgstr ""
+msgid "AlertManagement|All alerts"
+msgstr ""
+
msgid "AlertManagement|Authorize external service"
msgstr ""
@@ -1742,6 +1745,9 @@ msgstr ""
msgid "AlertManagement|No alerts to display."
msgstr ""
+msgid "AlertManagement|Open"
+msgstr ""
+
msgid "AlertManagement|Overview"
msgstr ""
@@ -7214,15 +7220,27 @@ msgstr ""
msgid "DesignManagement|Adding a design with the same filename replaces the file in a new version."
msgstr ""
+msgid "DesignManagement|Are you sure you want to cancel changes to this comment?"
+msgstr ""
+
msgid "DesignManagement|Are you sure you want to cancel creating this comment?"
msgstr ""
msgid "DesignManagement|Are you sure you want to delete the selected designs?"
msgstr ""
+msgid "DesignManagement|Cancel changes"
+msgstr ""
+
msgid "DesignManagement|Cancel comment confirmation"
msgstr ""
+msgid "DesignManagement|Cancel comment update confirmation"
+msgstr ""
+
+msgid "DesignManagement|Comment"
+msgstr ""
+
msgid "DesignManagement|Could not add a new comment. Please try again."
msgstr ""
@@ -7232,6 +7250,9 @@ msgstr ""
msgid "DesignManagement|Could not update discussion. Please try again."
msgstr ""
+msgid "DesignManagement|Could not update note. Please try again."
+msgstr ""
+
msgid "DesignManagement|Delete"
msgstr ""
@@ -7259,12 +7280,18 @@ msgstr ""
msgid "DesignManagement|Go to previous design"
msgstr ""
+msgid "DesignManagement|Keep changes"
+msgstr ""
+
msgid "DesignManagement|Keep comment"
msgstr ""
msgid "DesignManagement|Requested design version does not exist. Showing latest version instead"
msgstr ""
+msgid "DesignManagement|Save comment"
+msgstr ""
+
msgid "DesignManagement|Select all"
msgstr ""
@@ -13081,6 +13108,9 @@ msgstr ""
msgid "MergeRequests|Add a reply"
msgstr ""
+msgid "MergeRequests|An error occurred while checking whether another squash is in progress."
+msgstr ""
+
msgid "MergeRequests|An error occurred while saving the draft comment."
msgstr ""
@@ -13296,6 +13326,9 @@ msgstr ""
msgid "Metrics|Link contains an invalid time window, please verify the link to see the requested time range."
msgstr ""
+msgid "Metrics|Link contains invalid chart information, please verify the link to see the expanded panel."
+msgstr ""
+
msgid "Metrics|Max"
msgstr ""
@@ -15078,6 +15111,9 @@ msgstr ""
msgid "Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results."
msgstr ""
+msgid "Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}"
+msgstr ""
+
msgid "Pipelines settings for '%{project_name}' were successfully updated."
msgstr ""
@@ -15141,7 +15177,7 @@ msgstr ""
msgid "Pipeline|Commit"
msgstr ""
-msgid "Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation.%{linkEnd}"
+msgid "Pipeline|Could not retrieve the pipeline status. For troubleshooting steps, read the %{linkStart}documentation%{linkEnd}."
msgstr ""
msgid "Pipeline|Coverage"
@@ -15168,6 +15204,9 @@ msgstr ""
msgid "Pipeline|Merged result pipeline"
msgstr ""
+msgid "Pipeline|No pipeline has been run for this commit."
+msgstr ""
+
msgid "Pipeline|Pipeline"
msgstr ""
diff --git a/qa/qa/page/dashboard/snippet/show.rb b/qa/qa/page/dashboard/snippet/show.rb
index fc40b072032..2dcb11018ef 100644
--- a/qa/qa/page/dashboard/snippet/show.rb
+++ b/qa/qa/page/dashboard/snippet/show.rb
@@ -5,8 +5,8 @@ module QA
module Dashboard
module Snippet
class Show < Page::Base
- view 'app/assets/javascripts/snippets/components/snippet_description_edit.vue' do
- element :snippet_description_field, required: true
+ view 'app/assets/javascripts/snippets/components/snippet_description_view.vue' do
+ element :snippet_description_field
end
view 'app/assets/javascripts/snippets/components/snippet_title.vue' do
diff --git a/spec/factories/alert_management/alerts.rb b/spec/factories/alert_management/alerts.rb
index 20bbfffc6c6..28cfe5d6b29 100644
--- a/spec/factories/alert_management/alerts.rb
+++ b/spec/factories/alert_management/alerts.rb
@@ -31,8 +31,23 @@ FactoryBot.define do
ended_at { Time.current }
end
+ trait :without_ended_at do
+ ended_at { nil }
+ end
+
+ trait :acknowledged do
+ status { AlertManagement::Alert::STATUSES[:acknowledged] }
+ without_ended_at
+ end
+
trait :resolved do
- status { :resolved }
+ status { AlertManagement::Alert::STATUSES[:resolved] }
+ with_ended_at
+ end
+
+ trait :ignored do
+ status { AlertManagement::Alert::STATUSES[:ignored] }
+ without_ended_at
end
trait :all_fields do
@@ -41,7 +56,6 @@ FactoryBot.define do
with_service
with_monitoring_tool
with_host
- with_ended_at
end
end
end
diff --git a/spec/finders/alert_management/alerts_finder_spec.rb b/spec/finders/alert_management/alerts_finder_spec.rb
index c25585a5da5..8ce5216ba9a 100644
--- a/spec/finders/alert_management/alerts_finder_spec.rb
+++ b/spec/finders/alert_management/alerts_finder_spec.rb
@@ -5,8 +5,8 @@ require 'spec_helper'
describe AlertManagement::AlertsFinder, '#execute' do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
- let_it_be(:alert_1) { create(:alert_management_alert, project: project, ended_at: 1.year.ago, events: 2, severity: :high, status: :resolved) }
- let_it_be(:alert_2) { create(:alert_management_alert, project: project, events: 1, severity: :critical, status: :ignored) }
+ let_it_be(:alert_1) { create(:alert_management_alert, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) }
+ let_it_be(:alert_2) { create(:alert_management_alert, :ignored, project: project, events: 1, severity: :critical) }
let_it_be(:alert_3) { create(:alert_management_alert) }
let(:params) { {} }
@@ -155,10 +155,10 @@ describe AlertManagement::AlertsFinder, '#execute' do
end
context 'when sorting by status' do
- let_it_be(:alert_triggered) { create(:alert_management_alert, project: project, status: :triggered) }
- let_it_be(:alert_acknowledged) { create(:alert_management_alert, project: project, status: :acknowledged) }
- let_it_be(:alert_resolved) { create(:alert_management_alert, project: project, status: :resolved) }
- let_it_be(:alert_ignored) { create(:alert_management_alert, project: project, status: :ignored) }
+ let_it_be(:alert_triggered) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert_acknowledged) { create(:alert_management_alert, :acknowledged, project: project) }
+ let_it_be(:alert_resolved) { create(:alert_management_alert, :resolved, project: project) }
+ let_it_be(:alert_ignored) { create(:alert_management_alert, :ignored, project: project) }
context 'sorts alerts ascending' do
let(:params) { { sort: 'status_asc' } }
diff --git a/spec/frontend/alert_management/components/alert_management_list_spec.js b/spec/frontend/alert_management/components/alert_management_list_spec.js
index c0494d736a6..cb2531663bd 100644
--- a/spec/frontend/alert_management/components/alert_management_list_spec.js
+++ b/spec/frontend/alert_management/components/alert_management_list_spec.js
@@ -1,6 +1,16 @@
import { mount } from '@vue/test-utils';
-import { GlEmptyState, GlTable, GlAlert, GlLoadingIcon, GlNewDropdown, GlIcon } from '@gitlab/ui';
+import {
+ GlEmptyState,
+ GlTable,
+ GlAlert,
+ GlLoadingIcon,
+ GlNewDropdown,
+ GlBadge,
+ GlIcon,
+ GlTab,
+} from '@gitlab/ui';
import AlertManagementList from '~/alert_management/components/alert_management_list.vue';
+import { ALERTS_STATUS_TABS } from '../../../../app/assets/javascripts/alert_management/constants';
import mockAlerts from '../mocks/alerts.json';
@@ -12,6 +22,8 @@ describe('AlertManagementList', () => {
const findAlert = () => wrapper.find(GlAlert);
const findLoader = () => wrapper.find(GlLoadingIcon);
const findStatusDropdown = () => wrapper.find(GlNewDropdown);
+ const findStatusFilterTabs = () => wrapper.findAll(GlTab);
+ const findNumberOfAlertsBadge = () => wrapper.findAll(GlBadge);
function mountComponent({
props = {
@@ -20,6 +32,8 @@ describe('AlertManagementList', () => {
},
data = {},
loading = false,
+ alertListStatusFilteringEnabled = false,
+ stubs = {},
} = {}) {
wrapper = mount(AlertManagementList, {
propsData: {
@@ -28,6 +42,9 @@ describe('AlertManagementList', () => {
emptyAlertSvgPath: 'illustration/path',
...props,
},
+ provide: {
+ glFeatures: { alertListStatusFilteringEnabled },
+ },
data() {
return data;
},
@@ -40,6 +57,7 @@ describe('AlertManagementList', () => {
},
},
},
+ stubs,
});
}
@@ -59,6 +77,56 @@ describe('AlertManagementList', () => {
});
});
+ describe('Status Filter Tabs', () => {
+ describe('alertListStatusFilteringEnabled feature flag enabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts },
+ loading: false,
+ alertListStatusFilteringEnabled: true,
+ stubs: {
+ GlTab: true,
+ },
+ });
+ });
+
+ it('should display filter tabs for all statuses', () => {
+ const tabs = findStatusFilterTabs().wrappers;
+ tabs.forEach((tab, i) => {
+ expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title);
+ });
+ });
+
+ it('should have number of items badge along with status tab', () => {
+ expect(findNumberOfAlertsBadge().length).toEqual(ALERTS_STATUS_TABS.length);
+ expect(
+ findNumberOfAlertsBadge()
+ .at(0)
+ .text(),
+ ).toEqual(`${mockAlerts.length}`);
+ });
+ });
+
+ describe('alertListStatusFilteringEnabled feature flag disabled', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
+ data: { alerts: mockAlerts },
+ loading: false,
+ alertListStatusFilteringEnabled: false,
+ stubs: {
+ GlTab: true,
+ },
+ });
+ });
+
+ it('should NOT display tabs', () => {
+ expect(findStatusFilterTabs()).not.toExist();
+ });
+ });
+ });
+
describe('Alerts table', () => {
it('loading state', () => {
mountComponent({
diff --git a/spec/frontend/code_navigation/store/actions_spec.js b/spec/frontend/code_navigation/store/actions_spec.js
index 3996e8ea1f5..4cf77ed1be5 100644
--- a/spec/frontend/code_navigation/store/actions_spec.js
+++ b/spec/frontend/code_navigation/store/actions_spec.js
@@ -143,6 +143,16 @@ describe('Code navigation actions', () => {
expect(addInteractionClass.mock.calls[0]).toEqual(['index.js', 'test']);
expect(addInteractionClass.mock.calls[1]).toEqual(['index.js', 'console.log']);
});
+
+ it('does not call addInteractionClass when no data exists', () => {
+ const state = {
+ data: null,
+ };
+
+ actions.showBlobInteractionZones({ state }, 'index.js');
+
+ expect(addInteractionClass).not.toHaveBeenCalled();
+ });
});
describe('showDefinition', () => {
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
index c6d8f9fe174..84e52e1099a 100644
--- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap
@@ -3,7 +3,7 @@
exports[`Design note component should match the snapshot 1`] = `
<timeline-entry-item-stub
class="design-note note-form"
- id="note_undefined"
+ id="note_123"
>
<user-avatar-link-stub
imgalt=""
@@ -16,50 +16,55 @@ exports[`Design note component should match the snapshot 1`] = `
username=""
/>
- <a
- class="js-user-link"
- data-user-id="author-id"
- >
- <span
- class="note-header-author-name bold"
- >
-
- </span>
-
- <!---->
-
- <span
- class="note-headline-light"
- >
- @
- </span>
- </a>
-
- <span
- class="note-headline-light note-headline-meta"
+ <div
+ class="d-flex justify-content-between"
>
- <span
- class="system-note-message"
- />
-
- <span
- class="system-note-separator"
- />
+ <div>
+ <a
+ class="js-user-link"
+ data-user-id="author-id"
+ >
+ <span
+ class="note-header-author-name bold"
+ >
+
+ </span>
+
+ <!---->
+
+ <span
+ class="note-headline-light"
+ >
+ @
+ </span>
+ </a>
+
+ <span
+ class="note-headline-light note-headline-meta"
+ >
+ <span
+ class="system-note-message"
+ />
+
+ <!---->
+ </span>
+ </div>
- <a
- class="note-timestamp system-note-separator"
- href="#note_undefined"
+ <button
+ class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
+ title="Edit comment"
+ type="button"
>
- <time-ago-tooltip-stub
- cssclass=""
- time="2019-07-26T15:02:20Z"
- tooltipplacement="bottom"
+ <gl-icon-stub
+ class="link-highlight"
+ name="pencil"
+ size="16"
/>
- </a>
- </span>
+ </button>
+ </div>
<div
- class="note-text md"
+ class="note-text js-note-text md"
data-qa-selector="note_content"
/>
</timeline-entry-item-stub>
diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
new file mode 100644
index 00000000000..e01c79e3520
--- /dev/null
+++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+ <!---->
+ Comment
+</button>"
+`;
+
+exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
+"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
+ <!---->
+ Save comment
+</button>"
+`;
diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js
index 4e5b7a66611..228ee9df00e 100644
--- a/spec/frontend/design_management/components/design_notes/design_note_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js
@@ -1,29 +1,54 @@
import { shallowMount } from '@vue/test-utils';
+import { ApolloMutation } from 'vue-apollo';
import DesignNote from '~/design_management/components/design_notes/design_note.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
const scrollIntoViewMock = jest.fn();
+const note = {
+ id: 'gid://gitlab/DiffNote/123',
+ author: {
+ id: 'author-id',
+ },
+ body: 'test',
+};
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
const $route = {
hash: '#note_123',
};
+const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
+
describe('Design note component', () => {
let wrapper;
const findUserAvatar = () => wrapper.find(UserAvatarLink);
const findUserLink = () => wrapper.find('.js-user-link');
+ const findReplyForm = () => wrapper.find(DesignReplyForm);
+ const findEditButton = () => wrapper.find('.js-note-edit');
+ const findNoteContent = () => wrapper.find('.js-note-text');
- function createComponent(props = {}) {
+ function createComponent(props = {}, data = { isEditing: false }) {
wrapper = shallowMount(DesignNote, {
propsData: {
note: {},
...props,
},
+ data() {
+ return {
+ ...data,
+ };
+ },
mocks: {
$route,
+ $apollo: {
+ mutate,
+ },
+ },
+ stubs: {
+ ApolloMutation,
},
});
}
@@ -34,13 +59,7 @@ describe('Design note component', () => {
it('should match the snapshot', () => {
createComponent({
- note: {
- id: '1',
- createdAt: '2019-07-26T15:02:20Z',
- author: {
- id: 'author-id',
- },
- },
+ note,
});
expect(wrapper.element).toMatchSnapshot();
@@ -48,12 +67,7 @@ describe('Design note component', () => {
it('should render an author', () => {
createComponent({
- note: {
- id: '1',
- author: {
- id: 'author-id',
- },
- },
+ note,
});
expect(findUserAvatar().exists()).toBe(true);
@@ -63,11 +77,8 @@ describe('Design note component', () => {
it('should render a time ago tooltip if note has createdAt property', () => {
createComponent({
note: {
- id: '1',
+ ...note,
createdAt: '2019-07-26T15:02:20Z',
- author: {
- id: 'author-id',
- },
},
});
@@ -76,14 +87,61 @@ describe('Design note component', () => {
it('should trigger a scrollIntoView method', () => {
createComponent({
- note: {
- id: 'gid://gitlab/DiffNote/123',
- author: {
- id: 'author-id',
- },
- },
+ note,
});
expect(scrollIntoViewMock).toHaveBeenCalled();
});
+
+ it('should open an edit form on edit button click', () => {
+ createComponent({
+ note,
+ });
+
+ findEditButton().trigger('click');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyForm().exists()).toBe(true);
+ expect(findNoteContent().exists()).toBe(false);
+ });
+ });
+
+ describe('when edit form is rendered', () => {
+ beforeEach(() => {
+ createComponent(
+ {
+ note,
+ },
+ { isEditing: true },
+ );
+ });
+
+ it('should not render note content and should render reply form', () => {
+ expect(findNoteContent().exists()).toBe(false);
+ expect(findReplyForm().exists()).toBe(true);
+ });
+
+ it('hides the form on hideForm event', () => {
+ findReplyForm().vm.$emit('cancelForm');
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ expect(findNoteContent().exists()).toBe(true);
+ });
+ });
+
+ it('calls a mutation on submitForm event and hides a form', () => {
+ findReplyForm().vm.$emit('submitForm');
+ expect(mutate).toHaveBeenCalled();
+
+ return mutate()
+ .then(() => {
+ return wrapper.vm.$nextTick();
+ })
+ .then(() => {
+ expect(findReplyForm().exists()).toBe(false);
+ expect(findNoteContent().exists()).toBe(true);
+ });
+ });
+ });
});
diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
index 0780a9017f4..34b8f1f9fa8 100644
--- a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
@@ -1,6 +1,15 @@
import { mount } from '@vue/test-utils';
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
+const showModal = jest.fn();
+
+const GlModal = {
+ template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
+ methods: {
+ show: showModal,
+ },
+};
+
describe('Design reply form component', () => {
let wrapper;
@@ -16,6 +25,7 @@ describe('Design reply form component', () => {
isSaving: false,
...props,
},
+ stubs: { GlModal },
});
}
@@ -29,6 +39,18 @@ describe('Design reply form component', () => {
expect(findTextarea().element).toEqual(document.activeElement);
});
+ it('renders button text as "Comment" when creating a comment', () => {
+ createComponent();
+
+ expect(findSubmitButton().html()).toMatchSnapshot();
+ });
+
+ it('renders button text as "Save comment" when creating a comment', () => {
+ createComponent({ isNewComment: false });
+
+ expect(findSubmitButton().html()).toMatchSnapshot();
+ });
+
describe('when form has no text', () => {
beforeEach(() => {
createComponent({
@@ -120,16 +142,34 @@ describe('Design reply form component', () => {
});
});
- it('opens confirmation modal on pressing Escape button', () => {
+ it('emits cancelForm event on Escape key if text was not changed', () => {
findTextarea().trigger('keyup.esc');
- expect(findModal().exists()).toBe(true);
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
});
- it('opens confirmation modal on Cancel button click', () => {
- findCancelButton().vm.$emit('click');
+ it('opens confirmation modal on Escape key when text has changed', () => {
+ wrapper.setProps({ value: 'test2' });
- expect(findModal().exists()).toBe(true);
+ return wrapper.vm.$nextTick().then(() => {
+ findTextarea().trigger('keyup.esc');
+ expect(showModal).toHaveBeenCalled();
+ });
+ });
+
+ it('emits cancelForm event on Cancel button click if text was not changed', () => {
+ findCancelButton().trigger('click');
+
+ expect(wrapper.emitted('cancelForm')).toBeTruthy();
+ });
+
+ it('opens confirmation modal on Cancel button click when text has changed', () => {
+ wrapper.setProps({ value: 'test2' });
+
+ return wrapper.vm.$nextTick().then(() => {
+ findCancelButton().trigger('click');
+ expect(showModal).toHaveBeenCalled();
+ });
});
it('emits cancelForm event on modal Ok button click', () => {
diff --git a/spec/javascripts/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js
index 106264aa13f..cb07bcf8f28 100644
--- a/spec/javascripts/editor/editor_lite_spec.js
+++ b/spec/frontend/editor/editor_lite_spec.js
@@ -1,4 +1,4 @@
-import { editor as monacoEditor, Uri } from 'monaco-editor';
+import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
import Editor from '~/editor/editor_lite';
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
@@ -41,13 +41,13 @@ describe('Base editor', () => {
let dispose;
beforeEach(() => {
- setModel = jasmine.createSpy();
- dispose = jasmine.createSpy();
- modelSpy = spyOn(monacoEditor, 'createModel').and.returnValue(fakeModel);
- instanceSpy = spyOn(monacoEditor, 'create').and.returnValue({
+ setModel = jest.fn();
+ dispose = jest.fn();
+ modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => fakeModel);
+ instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({
setModel,
dispose,
- });
+ }));
});
it('does nothing if no dom element is supplied', () => {
@@ -73,7 +73,7 @@ describe('Base editor', () => {
editor.createInstance({ el: editorEl });
expect(editor.editorEl).not.toBe(null);
- expect(instanceSpy).toHaveBeenCalledWith(editorEl, jasmine.anything());
+ expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything());
});
});
@@ -91,6 +91,11 @@ describe('Base editor', () => {
});
it('is capable of changing the language of the model', () => {
+ // ignore warnings and errors Monaco posts during setup
+ // (due to being called from Jest/Node.js environment)
+ jest.spyOn(console, 'warn').mockImplementation(() => {});
+ jest.spyOn(console, 'error').mockImplementation(() => {});
+
const blobRenamedPath = 'test.js';
expect(editor.model.getLanguageIdentifier().language).toEqual('markdown');
@@ -101,7 +106,7 @@ describe('Base editor', () => {
it('falls back to plaintext if there is no language associated with an extension', () => {
const blobRenamedPath = 'test.myext';
- const spy = spyOn(console, 'error');
+ const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
editor.updateModelLanguage(blobRenamedPath);
@@ -110,14 +115,26 @@ describe('Base editor', () => {
});
});
+ describe('languages', () => {
+ it('registers custom languages defined with Monaco', () => {
+ expect(monacoLanguages.getLanguages()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'vue',
+ }),
+ ]),
+ );
+ });
+ });
+
describe('syntax highlighting theme', () => {
let themeDefineSpy;
let themeSetSpy;
let defaultScheme;
beforeEach(() => {
- themeDefineSpy = spyOn(monacoEditor, 'defineTheme');
- themeSetSpy = spyOn(monacoEditor, 'setTheme');
+ themeDefineSpy = jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(() => {});
+ themeSetSpy = jest.spyOn(monacoEditor, 'setTheme').mockImplementation(() => {});
defaultScheme = window.gon.user_color_scheme;
});
diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js
index 78e7bf5b58a..36d4c3c26ee 100644
--- a/spec/frontend/ide/lib/editor_spec.js
+++ b/spec/frontend/ide/lib/editor_spec.js
@@ -1,4 +1,4 @@
-import { editor as monacoEditor } from 'monaco-editor';
+import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
import Editor from '~/ide/lib/editor';
import { defaultEditorOptions } from '~/ide/lib/editor_options';
import { file } from '../helpers';
@@ -181,6 +181,18 @@ describe('Multi-file editor library', () => {
});
});
+ describe('languages', () => {
+ it('registers custom languages defined with Monaco', () => {
+ expect(monacoLanguages.getLanguages()).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: 'vue',
+ }),
+ ]),
+ );
+ });
+ });
+
describe('dispose', () => {
it('calls disposble dispose method', () => {
jest.spyOn(instance.disposable, 'dispose');
diff --git a/spec/frontend/ide/lib/languages/vue_spec.js b/spec/frontend/ide/lib/languages/vue_spec.js
new file mode 100644
index 00000000000..3d8784c1436
--- /dev/null
+++ b/spec/frontend/ide/lib/languages/vue_spec.js
@@ -0,0 +1,92 @@
+import { editor } from 'monaco-editor';
+import { registerLanguages } from '~/ide/utils';
+import vue from '~/ide/lib/languages/vue';
+
+// This file only tests syntax specific to vue. This does not test existing syntaxes
+// of html, javascript, css and handlebars, which vue files extend.
+describe('tokenization for .vue files', () => {
+ beforeEach(() => {
+ registerLanguages(vue);
+ });
+
+ test.each([
+ [
+ '<div v-if="something">content</div>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 4, type: '' },
+ { language: 'vue', offset: 5, type: 'variable' },
+ { language: 'vue', offset: 21, type: 'delimiter.html' },
+ { language: 'vue', offset: 22, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 34, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<input :placeholder="placeholder">',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 6, type: '' },
+ { language: 'vue', offset: 7, type: 'variable' },
+ { language: 'vue', offset: 33, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<gl-modal @ok="submitForm()"></gl-modal>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 3, type: 'attribute.name' },
+ { language: 'vue', offset: 9, type: '' },
+ { language: 'vue', offset: 10, type: 'variable' },
+ { language: 'vue', offset: 28, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 33, type: 'attribute.name' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a v-on:click.stop="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ { language: 'vue', offset: 33, type: '' },
+ { language: 'vue', offset: 36, type: 'delimiter.html' },
+ { language: 'vue', offset: 38, type: 'tag.html' },
+ { language: 'vue', offset: 39, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ [
+ '<a @[event]="doSomething">...</a>',
+ [
+ [
+ { language: 'vue', offset: 0, type: 'delimiter.html' },
+ { language: 'vue', offset: 1, type: 'tag.html' },
+ { language: 'vue', offset: 2, type: '' },
+ { language: 'vue', offset: 3, type: 'variable' },
+ { language: 'vue', offset: 25, type: 'delimiter.html' },
+ { language: 'vue', offset: 26, type: '' },
+ { language: 'vue', offset: 29, type: 'delimiter.html' },
+ { language: 'vue', offset: 31, type: 'tag.html' },
+ { language: 'vue', offset: 32, type: 'delimiter.html' },
+ ],
+ ],
+ ],
+ ])('%s', (string, tokens) => {
+ expect(editor.tokenize(string, 'vue')).toEqual(tokens);
+ });
+});
diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js
index 44eae7eacbe..10a31842dd4 100644
--- a/spec/frontend/ide/utils_spec.js
+++ b/spec/frontend/ide/utils_spec.js
@@ -1,6 +1,7 @@
import { commitItemIconMap } from '~/ide/constants';
-import { getCommitIconMap, isTextFile } from '~/ide/utils';
+import { getCommitIconMap, isTextFile, registerLanguages } from '~/ide/utils';
import { decorateData } from '~/ide/stores/utils';
+import { languages } from 'monaco-editor';
describe('WebIDE utils', () => {
describe('isTextFile', () => {
@@ -102,4 +103,78 @@ describe('WebIDE utils', () => {
expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified);
});
});
+
+ describe('registerLanguages', () => {
+ let langs;
+
+ beforeEach(() => {
+ langs = [
+ {
+ id: 'html',
+ extensions: ['.html'],
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'css',
+ extensions: ['.css'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ {
+ id: 'js',
+ extensions: ['.js'],
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ language: { tokenizer: {} },
+ },
+ ];
+
+ jest.spyOn(languages, 'register').mockImplementation(() => {});
+ jest.spyOn(languages, 'setMonarchTokensProvider').mockImplementation(() => {});
+ jest.spyOn(languages, 'setLanguageConfiguration').mockImplementation(() => {});
+ });
+
+ it('registers all the passed languages with Monaco', () => {
+ registerLanguages(...langs);
+
+ expect(languages.register.mock.calls).toEqual([
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.css'],
+ id: 'css',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['/*', '*/'] } },
+ extensions: ['.js'],
+ id: 'js',
+ language: { tokenizer: {} },
+ },
+ ],
+ [
+ {
+ conf: { comments: { blockComment: ['<!--', '-->'] } },
+ extensions: ['.html'],
+ id: 'html',
+ language: { tokenizer: {} },
+ },
+ ],
+ ]);
+
+ expect(languages.setMonarchTokensProvider.mock.calls).toEqual([
+ ['css', { tokenizer: {} }],
+ ['js', { tokenizer: {} }],
+ ['html', { tokenizer: {} }],
+ ]);
+
+ expect(languages.setLanguageConfiguration.mock.calls).toEqual([
+ ['css', { comments: { blockComment: ['/*', '*/'] } }],
+ ['js', { comments: { blockComment: ['/*', '*/'] } }],
+ ['html', { comments: { blockComment: ['<!--', '-->'] } }],
+ ]);
+ });
+ });
});
diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js
index ccc29623c31..fb7fb24a341 100644
--- a/spec/frontend/monitoring/components/dashboard_panel_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js
@@ -376,10 +376,6 @@ describe('Dashboard Panel', () => {
});
});
- afterEach(() => {
- wrapper.destroy();
- });
-
it('sets clipboard text on the dropdown', () => {
expect(findCopyLink().exists()).toBe(true);
expect(findCopyLink().element.dataset.clipboardText).toBe(clipboardText);
@@ -396,6 +392,18 @@ describe('Dashboard Panel', () => {
});
});
+ describe('when cliboard data is not available', () => {
+ it('there is no "copy to clipboard" link for a null value', () => {
+ createWrapper({ clipboardText: null });
+ expect(findCopyLink().exists()).toBe(false);
+ });
+
+ it('there is no "copy to clipboard" link for an empty value', () => {
+ createWrapper({ clipboardText: '' });
+ expect(findCopyLink().exists()).toBe(false);
+ });
+ });
+
describe('when downloading metrics data as CSV', () => {
beforeEach(() => {
wrapper = shallowMount(DashboardPanel, {
diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js
index 24883e9055e..12cb2c588dd 100644
--- a/spec/frontend/monitoring/components/dashboard_spec.js
+++ b/spec/frontend/monitoring/components/dashboard_spec.js
@@ -2,6 +2,7 @@ import { shallowMount, mount } from '@vue/test-utils';
import Tracking from '~/tracking';
import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys';
import { GlModal, GlDropdownItem, GlDeprecatedButton } from '@gitlab/ui';
+import { objectToQuery } from '~/lib/utils/url_utility';
import VueDraggable from 'vuedraggable';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
@@ -20,6 +21,9 @@ import * as types from '~/monitoring/stores/mutation_types';
import { setupStoreWithDashboard, setMetricResult, setupStoreWithData } from '../store_utils';
import { environmentData, dashboardGitResponse, propsData } from '../mock_data';
import { metricsDashboardViewModel, metricsDashboardPanelCount } from '../fixture_data';
+import createFlash from '~/flash';
+
+jest.mock('~/flash');
describe('Dashboard', () => {
let store;
@@ -64,7 +68,6 @@ describe('Dashboard', () => {
describe('no metrics are available yet', () => {
beforeEach(() => {
- jest.spyOn(store, 'dispatch');
createShallowWrapper();
});
@@ -150,6 +153,87 @@ describe('Dashboard', () => {
});
});
+ describe('when the URL contains a reference to a panel', () => {
+ let location;
+
+ const setSearch = search => {
+ window.location = { ...location, search };
+ };
+
+ beforeEach(() => {
+ location = window.location;
+ delete window.location;
+ });
+
+ afterEach(() => {
+ window.location = location;
+ });
+
+ it('when the URL points to a panel it expands', () => {
+ const panelGroup = metricsDashboardViewModel.panelGroups[0];
+ const panel = panelGroup.panels[0];
+
+ setSearch(
+ objectToQuery({
+ group: panelGroup.group,
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ );
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(wrapper.vm.$store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setExpandedPanel', {
+ group: panelGroup.group,
+ panel: expect.objectContaining({
+ title: panel.title,
+ y_label: panel.y_label,
+ }),
+ });
+ });
+ });
+
+ it('when the URL does not link to any panel, no panel is expanded', () => {
+ setSearch('');
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(wrapper.vm.$store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'monitoringDashboard/setExpandedPanel',
+ expect.anything(),
+ );
+ });
+ });
+
+ it('when the URL points to an incorrect panel it shows an error', () => {
+ const panelGroup = metricsDashboardViewModel.panelGroups[0];
+ const panel = panelGroup.panels[0];
+
+ setSearch(
+ objectToQuery({
+ group: panelGroup.group,
+ title: 'incorrect',
+ y_label: panel.y_label,
+ }),
+ );
+
+ createMountedWrapper({ hasMetrics: true });
+ setupStoreWithData(wrapper.vm.$store);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(createFlash).toHaveBeenCalled();
+ expect(store.dispatch).not.toHaveBeenCalledWith(
+ 'monitoringDashboard/setExpandedPanel',
+ expect.anything(),
+ );
+ });
+ });
+ });
+
describe('when all requests have been commited by the store', () => {
beforeEach(() => {
createMountedWrapper({ hasMetrics: true });
diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js
index 9039dd3d6db..fd8fb4c6418 100644
--- a/spec/frontend/monitoring/store/actions_spec.js
+++ b/spec/frontend/monitoring/store/actions_spec.js
@@ -11,6 +11,7 @@ import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import store from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
import {
+ fetchData,
fetchDashboard,
receiveMetricsDashboardSuccess,
fetchDeploymentsData,
@@ -86,6 +87,41 @@ describe('Monitoring store actions', () => {
createFlash.mockReset();
});
+ describe('fetchData', () => {
+ it('dispatches fetchEnvironmentsData and fetchEnvironmentsData', () => {
+ const { state } = store;
+
+ return testAction(
+ fetchData,
+ null,
+ state,
+ [],
+ [{ type: 'fetchEnvironmentsData' }, { type: 'fetchDashboard' }],
+ );
+ });
+
+ it('dispatches when feature metricsDashboardAnnotations is on', () => {
+ const origGon = window.gon;
+ window.gon = { features: { metricsDashboardAnnotations: true } };
+
+ const { state } = store;
+
+ return testAction(
+ fetchData,
+ null,
+ state,
+ [],
+ [
+ { type: 'fetchEnvironmentsData' },
+ { type: 'fetchDashboard' },
+ { type: 'fetchAnnotations' },
+ ],
+ ).then(() => {
+ window.gon = origGon;
+ });
+ });
+ });
+
describe('fetchDeploymentsData', () => {
it('dispatches receiveDeploymentsDataSuccess on success', () => {
const { state } = store;
diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js
index 964c462988c..639330446be 100644
--- a/spec/frontend/monitoring/utils_spec.js
+++ b/spec/frontend/monitoring/utils_spec.js
@@ -1,5 +1,5 @@
import * as monitoringUtils from '~/monitoring/utils';
-import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
+import * as urlUtils from '~/lib/utils/url_utility';
import { TEST_HOST } from 'jest/helpers/test_constants';
import {
mockProjectDir,
@@ -7,9 +7,7 @@ import {
anomalyMockGraphData,
barMockData,
} from './mock_data';
-import { graphData } from './fixture_data';
-
-jest.mock('~/lib/utils/url_utility');
+import { metricsDashboardViewModel, graphData } from './fixture_data';
const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`;
@@ -27,11 +25,6 @@ const rollingRange = {
};
describe('monitoring/utils', () => {
- afterEach(() => {
- mergeUrlParams.mockReset();
- queryToObject.mockReset();
- });
-
describe('trackGenerateLinkToChartEventOptions', () => {
it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
document.body.dataset.page = 'groups:clusters:show';
@@ -139,18 +132,25 @@ describe('monitoring/utils', () => {
});
describe('timeRangeFromUrl', () => {
- const { timeRangeFromUrl } = monitoringUtils;
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'queryToObject');
+ });
- it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
- queryToObject.mockReturnValueOnce(range);
+ afterEach(() => {
+ urlUtils.queryToObject.mockRestore();
+ });
+
+ const { timeRangeFromUrl } = monitoringUtils;
+ it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
+ urlUtils.queryToObject.mockReturnValueOnce(range);
expect(timeRangeFromUrl()).toEqual(range);
});
- it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
+ it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
- queryToObject.mockReturnValueOnce({
+ urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboard/my_dashboard.yml',
duration_seconds: `${seconds}`,
});
@@ -158,23 +158,21 @@ describe('monitoring/utils', () => {
expect(timeRangeFromUrl()).toEqual(rollingRange);
});
- it('returns null when no time range paramters are given', () => {
- const params = {
+ it('returns null when no time range parameters are given', () => {
+ urlUtils.queryToObject.mockReturnValueOnce({
dashboard: '.gitlab/dashboards/custom_dashboard.yml',
param1: 'value1',
param2: 'value2',
- };
+ });
- expect(timeRangeFromUrl(params, mockPath)).toBe(null);
+ expect(timeRangeFromUrl()).toBe(null);
});
});
describe('removeTimeRangeParams', () => {
const { removeTimeRangeParams } = monitoringUtils;
- it('returns when query contains `start` and `end` paramters are given', () => {
- removeParams.mockReturnValueOnce(mockPath);
-
+ it('returns when query contains `start` and `end` parameters are given', () => {
expect(removeTimeRangeParams(`${mockPath}?start=${range.start}&end=${range.end}`)).toEqual(
mockPath,
);
@@ -184,28 +182,116 @@ describe('monitoring/utils', () => {
describe('timeRangeToUrl', () => {
const { timeRangeToUrl } = monitoringUtils;
- it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
+ beforeEach(() => {
+ jest.spyOn(urlUtils, 'mergeUrlParams');
+ jest.spyOn(urlUtils, 'removeParams');
+ });
+
+ afterEach(() => {
+ urlUtils.mergeUrlParams.mockRestore();
+ urlUtils.removeParams.mockRestore();
+ });
+
+ it('returns a fixed range when query contains `start` and `end` parameters are given', () => {
const toUrl = `${mockPath}?start=${range.start}&end=${range.end}`;
const fromUrl = mockPath;
- removeParams.mockReturnValueOnce(fromUrl);
- mergeUrlParams.mockReturnValueOnce(toUrl);
+ urlUtils.removeParams.mockReturnValueOnce(fromUrl);
+ urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(range)).toEqual(toUrl);
- expect(mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(range, fromUrl);
});
- it('returns a rolling range when query contains `duration_seconds` paramters are given', () => {
+ it('returns a rolling range when query contains `duration_seconds` parameters are given', () => {
const { seconds } = rollingRange.duration;
const toUrl = `${mockPath}?duration_seconds=${seconds}`;
const fromUrl = mockPath;
- removeParams.mockReturnValueOnce(fromUrl);
- mergeUrlParams.mockReturnValueOnce(toUrl);
+ urlUtils.removeParams.mockReturnValueOnce(fromUrl);
+ urlUtils.mergeUrlParams.mockReturnValueOnce(toUrl);
expect(timeRangeToUrl(rollingRange)).toEqual(toUrl);
- expect(mergeUrlParams).toHaveBeenCalledWith({ duration_seconds: `${seconds}` }, fromUrl);
+ expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(
+ { duration_seconds: `${seconds}` },
+ fromUrl,
+ );
+ });
+ });
+
+ describe('expandedPanelPayloadFromUrl', () => {
+ const { expandedPanelPayloadFromUrl } = monitoringUtils;
+ const [panelGroup] = metricsDashboardViewModel.panelGroups;
+ const [panel] = panelGroup.panels;
+
+ const { group } = panelGroup;
+ const { title, y_label: yLabel } = panel;
+
+ it('returns payload for a panel when query parameters are given', () => {
+ const search = `?group=${group}&title=${title}&y_label=${yLabel}`;
+
+ expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toEqual({
+ group: panelGroup.group,
+ panel,
+ });
+ });
+
+ it('returns null when no parameters are given', () => {
+ expect(expandedPanelPayloadFromUrl(metricsDashboardViewModel, '')).toBe(null);
+ });
+
+ it('throws an error when no group is provided', () => {
+ const search = `?title=${panel.title}&y_label=${yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ it('throws an error when no title is provided', () => {
+ const search = `?title=${title}&y_label=${yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ it('throws an error when no y_label group is provided', () => {
+ const search = `?group=${group}&title=${title}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+
+ test.each`
+ group | title | yLabel | missingField
+ ${'NOT_A_GROUP'} | ${title} | ${yLabel} | ${'group'}
+ ${group} | ${'NOT_A_TITLE'} | ${yLabel} | ${'title'}
+ ${group} | ${title} | ${'NOT_A_Y_LABEL'} | ${'y_label'}
+ `('throws an error when $missingField is incorrect', params => {
+ const search = `?group=${params.group}&title=${params.title}&y_label=${params.yLabel}`;
+ expect(() => expandedPanelPayloadFromUrl(metricsDashboardViewModel, search)).toThrow();
+ });
+ });
+
+ describe('panelToUrl', () => {
+ const { panelToUrl } = monitoringUtils;
+
+ const dashboard = 'metrics.yml';
+ const [panelGroup] = metricsDashboardViewModel.panelGroups;
+ const [panel] = panelGroup.panels;
+
+ it('returns URL for a panel when query parameters are given', () => {
+ const [, query] = panelToUrl(dashboard, panelGroup.group, panel).split('?');
+ const params = urlUtils.queryToObject(query);
+
+ expect(params).toEqual({
+ dashboard,
+ group: panelGroup.group,
+ title: panel.title,
+ y_label: panel.y_label,
+ });
+ });
+
+ it('returns `null` if group is missing', () => {
+ expect(panelToUrl(dashboard, null, panel)).toBe(null);
+ });
+
+ it('returns `null` if panel is missing', () => {
+ expect(panelToUrl(dashboard, panelGroup.group, null)).toBe(null);
});
});
diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap
new file mode 100644
index 00000000000..9ebc4e81baf
--- /dev/null
+++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_view_spec.js.snap
@@ -0,0 +1,16 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Snippet Description component matches the snapshot 1`] = `
+<markdown-field-view-stub
+ class="snippet-description"
+ data-qa-selector="snippet_description_field"
+>
+ <div
+ class="md js-snippet-description"
+ >
+ <h2>
+ The property of Thor
+ </h2>
+ </div>
+</markdown-field-view-stub>
+`;
diff --git a/spec/frontend/snippets/components/snippet_description_view_spec.js b/spec/frontend/snippets/components/snippet_description_view_spec.js
new file mode 100644
index 00000000000..46467ef311e
--- /dev/null
+++ b/spec/frontend/snippets/components/snippet_description_view_spec.js
@@ -0,0 +1,27 @@
+import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
+import { shallowMount } from '@vue/test-utils';
+
+describe('Snippet Description component', () => {
+ let wrapper;
+ const description = '<h2>The property of Thor</h2>';
+
+ function createComponent() {
+ wrapper = shallowMount(SnippetDescription, {
+ propsData: {
+ description,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('matches the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js
index b49b2008610..b9b60883eb3 100644
--- a/spec/frontend/snippets/components/snippet_title_spec.js
+++ b/spec/frontend/snippets/components/snippet_title_spec.js
@@ -1,4 +1,5 @@
import SnippetTitle from '~/snippets/components/snippet_title.vue';
+import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
@@ -36,8 +37,9 @@ describe('Snippet header component', () => {
it('renders snippets title and description', () => {
createComponent();
+
expect(wrapper.text().trim()).toContain(title);
- expect(wrapper.find('.js-snippet-description').element.innerHTML).toBe(descriptionHtml);
+ expect(wrapper.find(SnippetDescription).props('description')).toBe(descriptionHtml);
});
it('does not render recent changes time stamp if there were no updates', () => {
diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
index ce3f289eb6e..5cf42ecdc1d 100644
--- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
+++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js
@@ -1,5 +1,6 @@
import { shallowMount } from '@vue/test-utils';
import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue';
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
import { handleBlobRichViewer } from '~/blob/viewer';
jest.mock('~/blob/viewer');
@@ -33,4 +34,8 @@ describe('Blob Rich Viewer component', () => {
it('queries for advanced viewer', () => {
expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType);
});
+
+ it('is using Markdown View Field', () => {
+ expect(wrapper.contains(MarkdownFieldView)).toBe(true);
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/field_view_spec.js b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
new file mode 100644
index 00000000000..80cf1f655c6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/markdown/field_view_spec.js
@@ -0,0 +1,26 @@
+import $ from 'jquery';
+import { shallowMount } from '@vue/test-utils';
+
+import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
+
+describe('Markdown Field View component', () => {
+ let renderGFMSpy;
+ let wrapper;
+
+ function createComponent() {
+ wrapper = shallowMount(MarkdownFieldView);
+ }
+
+ beforeEach(() => {
+ renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('processes rendering with GFM', () => {
+ expect(renderGFMSpy).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/graphql/mutations/alert_management/update_alert_status_spec.rb b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
index 3cd5e217571..5263de117cf 100644
--- a/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
+++ b/spec/graphql/mutations/alert_management/update_alert_status_spec.rb
@@ -20,7 +20,7 @@ describe Mutations::AlertManagement::UpdateAlertStatus do
end
it 'changes the status' do
- expect { resolve }.to change { alert.reload.status }.from(alert.status).to(new_status)
+ expect { resolve }.to change { alert.reload.acknowledged? }.to(true)
end
it 'returns the alert with no errors' do
diff --git a/spec/graphql/resolvers/alert_management_alert_resolver_spec.rb b/spec/graphql/resolvers/alert_management_alert_resolver_spec.rb
index e550c13a3d2..c85d2cbccc6 100644
--- a/spec/graphql/resolvers/alert_management_alert_resolver_spec.rb
+++ b/spec/graphql/resolvers/alert_management_alert_resolver_spec.rb
@@ -7,8 +7,8 @@ describe Resolvers::AlertManagementAlertResolver do
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
- let_it_be(:alert_1) { create(:alert_management_alert, project: project, ended_at: 1.year.ago, events: 2, severity: :high, status: :resolved) }
- let_it_be(:alert_2) { create(:alert_management_alert, project: project, events: 1, severity: :critical, status: :ignored) }
+ let_it_be(:alert_1) { create(:alert_management_alert, :resolved, project: project, ended_at: 1.year.ago, events: 2, severity: :high) }
+ let_it_be(:alert_2) { create(:alert_management_alert, :ignored, project: project, events: 1, severity: :critical) }
let_it_be(:alert_other_proj) { create(:alert_management_alert) }
let(:args) { {} }
diff --git a/spec/graphql/types/alert_management/status_enum_spec.rb b/spec/graphql/types/alert_management/status_enum_spec.rb
index 8a1dc9841b0..240d8863c97 100644
--- a/spec/graphql/types/alert_management/status_enum_spec.rb
+++ b/spec/graphql/types/alert_management/status_enum_spec.rb
@@ -5,7 +5,20 @@ require 'spec_helper'
describe GitlabSchema.types['AlertManagementStatus'] do
specify { expect(described_class.graphql_name).to eq('AlertManagementStatus') }
- it 'exposes all the severity values' do
- expect(described_class.values.keys).to include(*%w[TRIGGERED ACKNOWLEDGED RESOLVED IGNORED])
+ describe 'statuses' do
+ using RSpec::Parameterized::TableSyntax
+
+ where(:status_name, :status_value) do
+ 'TRIGGERED' | 0
+ 'ACKNOWLEDGED' | 1
+ 'RESOLVED' | 2
+ 'IGNORED' | 3
+ end
+
+ with_them do
+ it 'exposes a status with the correct value' do
+ expect(described_class.values[status_name].value).to eq(status_value)
+ end
+ end
end
end
diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
index 5997c93105e..883c41085fa 100644
--- a/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/mr_widget_pipeline_spec.js
@@ -122,6 +122,19 @@ describe('MRWidgetPipeline', () => {
);
});
+ it('should render CI error when no CI is provided and pipeline must succeed is turned on', () => {
+ vm = mountComponent(Component, {
+ pipeline: {},
+ hasCi: false,
+ pipelineMustSucceed: true,
+ troubleshootingDocsPath: 'help',
+ });
+
+ expect(vm.$el.querySelector('.media-body').textContent.trim()).toContain(
+ 'No pipeline has been run for this commit.',
+ );
+ });
+
describe('with a pipeline', () => {
beforeEach(() => {
vm = mountComponent(Component, {
diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
index d396f2d9271..4e4f99f09e7 100644
--- a/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -18,6 +18,7 @@ const createTestMr = customConfig => {
isPipelineFailed: false,
isPipelinePassing: false,
isMergeAllowed: true,
+ isApproved: true,
onlyAllowMergeIfPipelineSucceeds: false,
ffOnlyEnabled: false,
hasCI: false,
diff --git a/spec/lib/gitlab/alert_management/alert_params_spec.rb b/spec/lib/gitlab/alert_management/alert_params_spec.rb
index 8c60b502417..601be961c2e 100644
--- a/spec/lib/gitlab/alert_management/alert_params_spec.rb
+++ b/spec/lib/gitlab/alert_management/alert_params_spec.rb
@@ -42,4 +42,43 @@ describe Gitlab::AlertManagement::AlertParams do
end
end
end
+
+ describe '.from_prometheus_alert' do
+ let(:payload) do
+ {
+ 'status' => 'firing',
+ 'labels' => {
+ 'alertname' => 'GitalyFileServerDown',
+ 'channel' => 'gitaly',
+ 'pager' => 'pagerduty',
+ 'severity' => 's1'
+ },
+ 'annotations' => {
+ 'description' => 'Alert description',
+ 'runbook' => 'troubleshooting/gitaly-down.md',
+ 'title' => 'Alert title'
+ },
+ 'startsAt' => '2020-04-27T10:10:22.265949279Z',
+ 'endsAt' => '0001-01-01T00:00:00Z',
+ 'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1',
+ 'fingerprint' => 'b6ac4d42057c43c1'
+ }
+ end
+ let(:parsed_alert) { Gitlab::Alerting::Alert.new(project: project, payload: payload) }
+
+ subject { described_class.from_prometheus_alert(project: project, parsed_alert: parsed_alert) }
+
+ it 'returns Alert-compatible params' do
+ is_expected.to eq(
+ project_id: project.id,
+ title: 'Alert title',
+ description: 'Alert description',
+ monitoring_tool: 'Prometheus',
+ payload: payload,
+ started_at: parsed_alert.starts_at,
+ ended_at: parsed_alert.ends_at,
+ fingerprint: parsed_alert.gitlab_fingerprint
+ )
+ end
+ end
end
diff --git a/spec/lib/gitlab/alerting/alert_spec.rb b/spec/lib/gitlab/alerting/alert_spec.rb
index 6d97f08af91..a0582515f3d 100644
--- a/spec/lib/gitlab/alerting/alert_spec.rb
+++ b/spec/lib/gitlab/alerting/alert_spec.rb
@@ -246,6 +246,30 @@ describe Gitlab::Alerting::Alert do
it_behaves_like 'parse payload', 'annotations/gitlab_incident_markdown'
end
+ describe '#gitlab_fingerprint' do
+ subject { alert.gitlab_fingerprint }
+
+ context 'when the alert is a GitLab managed alert' do
+ include_context 'gitlab alert'
+
+ it 'returns a fingerprint' do
+ plain_fingerprint = [alert.metric_id, alert.starts_at].join('/')
+
+ is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
+ end
+ end
+
+ context 'when the alert is from self managed Prometheus' do
+ include_context 'full query'
+
+ it 'returns a fingerprint' do
+ plain_fingerprint = [alert.starts_at, alert.title, alert.full_query].join('/')
+
+ is_expected.to eq(Digest::SHA1.hexdigest(plain_fingerprint))
+ end
+ end
+ end
+
describe '#valid?' do
before do
payload.update(
diff --git a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
index 8c4071a7ed1..7603a5ccfc0 100644
--- a/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
+++ b/spec/lib/gitlab/metrics/samplers/ruby_sampler_spec.rb
@@ -8,6 +8,7 @@ describe Gitlab::Metrics::Samplers::RubySampler do
before do
allow(Gitlab::Metrics::NullMetric).to receive(:instance).and_return(null_metric)
+ stub_env('enable_memory_uss_pss', "1")
end
describe '#initialize' do
@@ -19,20 +20,19 @@ describe Gitlab::Metrics::Samplers::RubySampler do
end
describe '#sample' do
- it 'samples various statistics' do
- expect(Gitlab::Metrics::System).to receive(:cpu_time)
- expect(Gitlab::Metrics::System).to receive(:file_descriptor_count)
- expect(Gitlab::Metrics::System).to receive(:memory_usage)
- expect(Gitlab::Metrics::System).to receive(:max_open_file_descriptors)
- expect(sampler).to receive(:sample_gc)
+ it 'adds a metric containing the process resident memory bytes' do
+ expect(Gitlab::Metrics::System).to receive(:memory_usage).and_return(9000)
+
+ expect(sampler.metrics[:process_resident_memory_bytes]).to receive(:set).with({}, 9000)
sampler.sample
end
- it 'adds a metric containing the process resident memory bytes' do
- expect(Gitlab::Metrics::System).to receive(:memory_usage).and_return(9000)
+ it 'adds a metric containing the process unique and proportional memory bytes' do
+ expect(Gitlab::Metrics::System).to receive(:memory_usage_uss_pss).and_return(uss: 9000, pss: 10_000)
- expect(sampler.metrics[:process_resident_memory_bytes]).to receive(:set).with({}, 9000)
+ expect(sampler.metrics[:process_unique_memory_bytes]).to receive(:set).with({}, 9000)
+ expect(sampler.metrics[:process_proportional_memory_bytes]).to receive(:set).with({}, 10_000)
sampler.sample
end
diff --git a/spec/lib/gitlab/metrics/system_spec.rb b/spec/lib/gitlab/metrics/system_spec.rb
index a5aa80686fd..37d26bd9d63 100644
--- a/spec/lib/gitlab/metrics/system_spec.rb
+++ b/spec/lib/gitlab/metrics/system_spec.rb
@@ -3,33 +3,122 @@
require 'spec_helper'
describe Gitlab::Metrics::System do
- if File.exist?('/proc')
+ context 'when /proc files exist' do
+ # Fixtures pulled from:
+ # Linux carbon 5.3.0-7648-generic #41~1586789791~19.10~9593806-Ubuntu SMP Mon Apr 13 17:50:40 UTC x86_64 x86_64 x86_64 GNU/Linux
+ let(:proc_status) do
+ # most rows omitted for brevity
+ <<~SNIP
+ Name: less
+ VmHWM: 2468 kB
+ VmRSS: 2468 kB
+ RssAnon: 260 kB
+ SNIP
+ end
+
+ let(:proc_smaps_rollup) do
+ # full snapshot
+ <<~SNIP
+ Rss: 2564 kB
+ Pss: 503 kB
+ Pss_Anon: 312 kB
+ Pss_File: 191 kB
+ Pss_Shmem: 0 kB
+ Shared_Clean: 2100 kB
+ Shared_Dirty: 0 kB
+ Private_Clean: 152 kB
+ Private_Dirty: 312 kB
+ Referenced: 2564 kB
+ Anonymous: 312 kB
+ LazyFree: 0 kB
+ AnonHugePages: 0 kB
+ ShmemPmdMapped: 0 kB
+ Shared_Hugetlb: 0 kB
+ Private_Hugetlb: 0 kB
+ Swap: 0 kB
+ SwapPss: 0 kB
+ Locked: 0 kB
+ SNIP
+ end
+
+ let(:proc_limits) do
+ # full snapshot
+ <<~SNIP
+ Limit Soft Limit Hard Limit Units
+ Max cpu time unlimited unlimited seconds
+ Max file size unlimited unlimited bytes
+ Max data size unlimited unlimited bytes
+ Max stack size 8388608 unlimited bytes
+ Max core file size 0 unlimited bytes
+ Max resident set unlimited unlimited bytes
+ Max processes 126519 126519 processes
+ Max open files 1024 1048576 files
+ Max locked memory 67108864 67108864 bytes
+ Max address space unlimited unlimited bytes
+ Max file locks unlimited unlimited locks
+ Max pending signals 126519 126519 signals
+ Max msgqueue size 819200 819200 bytes
+ Max nice priority 0 0
+ Max realtime priority 0 0
+ Max realtime timeout unlimited unlimited us
+ SNIP
+ end
+
describe '.memory_usage' do
- it "returns the process' memory usage in bytes" do
- expect(described_class.memory_usage).to be > 0
+ it "returns the process' resident set size (RSS) in bytes" do
+ mock_existing_proc_file('/proc/self/status', proc_status)
+
+ expect(described_class.memory_usage).to eq(2527232)
end
end
describe '.file_descriptor_count' do
it 'returns the amount of open file descriptors' do
- expect(described_class.file_descriptor_count).to be > 0
+ expect(Dir).to receive(:glob).and_return(['/some/path', '/some/other/path'])
+
+ expect(described_class.file_descriptor_count).to eq(2)
end
end
describe '.max_open_file_descriptors' do
it 'returns the max allowed open file descriptors' do
- expect(described_class.max_open_file_descriptors).to be > 0
+ mock_existing_proc_file('/proc/self/limits', proc_limits)
+
+ expect(described_class.max_open_file_descriptors).to eq(1024)
+ end
+ end
+
+ describe '.memory_usage_uss_pss' do
+ it "returns the process' unique and porportional set size (USS/PSS) in bytes" do
+ mock_existing_proc_file('/proc/self/smaps_rollup', proc_smaps_rollup)
+
+ # (Private_Clean (152 kB) + Private_Dirty (312 kB) + Private_Hugetlb (0 kB)) * 1024
+ expect(described_class.memory_usage_uss_pss).to eq(uss: 475136, pss: 515072)
end
end
- else
+ end
+
+ context 'when /proc files do not exist' do
+ before do
+ mock_missing_proc_file
+ end
+
describe '.memory_usage' do
- it 'returns 0.0' do
- expect(described_class.memory_usage).to eq(0.0)
+ it 'returns 0' do
+ expect(described_class.memory_usage).to eq(0)
+ end
+ end
+
+ describe '.memory_usage_uss_pss' do
+ it "returns 0 for all components" do
+ expect(described_class.memory_usage_uss_pss).to eq(uss: 0, pss: 0)
end
end
describe '.file_descriptor_count' do
it 'returns 0' do
+ expect(Dir).to receive(:glob).and_return([])
+
expect(described_class.file_descriptor_count).to eq(0)
end
end
@@ -98,4 +187,12 @@ describe Gitlab::Metrics::System do
expect(described_class.thread_cpu_duration(start_time)).to be_nil
end
end
+
+ def mock_existing_proc_file(path, content)
+ allow(File).to receive(:foreach).with(path) { |_path, &block| content.each_line(&block) }
+ end
+
+ def mock_missing_proc_file
+ allow(File).to receive(:foreach).and_raise(Errno::ENOENT)
+ end
end
diff --git a/spec/models/alert_management/alert_spec.rb b/spec/models/alert_management/alert_spec.rb
index b40b18aeb4c..8c0f1016cac 100644
--- a/spec/models/alert_management/alert_spec.rb
+++ b/spec/models/alert_management/alert_spec.rb
@@ -20,6 +20,62 @@ describe AlertManagement::Alert do
it { is_expected.to validate_length_of(:service).is_at_most(100) }
it { is_expected.to validate_length_of(:monitoring_tool).is_at_most(100) }
+ context 'when status is triggered' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, ended_at: Time.current) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
+ context 'when status is acknowledged' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert, :acknowledged) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, :acknowledged, ended_at: Time.current) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
+ context 'when status is resolved' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert, :resolved, ended_at: nil) }
+
+ it { is_expected.to be_invalid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, :resolved, ended_at: Time.current) }
+
+ it { is_expected.to be_valid }
+ end
+ end
+
+ context 'when status is ignored' do
+ context 'when ended_at is blank' do
+ subject { build(:alert_management_alert, :ignored) }
+
+ it { is_expected.to be_valid }
+ end
+
+ context 'when ended_at is present' do
+ subject { build(:alert_management_alert, :ignored, ended_at: Time.current) }
+
+ it { is_expected.to be_invalid }
+ end
+ end
+
describe 'fingerprint' do
let_it_be(:fingerprint) { 'fingerprint' }
let_it_be(:existing_alert) { create(:alert_management_alert, fingerprint: fingerprint) }
@@ -64,57 +120,7 @@ describe AlertManagement::Alert do
{ critical: 0, high: 1, medium: 2, low: 3, info: 4, unknown: 5 }
end
- let(:status_values) do
- { triggered: 0, acknowledged: 1, resolved: 2, ignored: 3 }
- end
-
it { is_expected.to define_enum_for(:severity).with_values(severity_values) }
- it { is_expected.to define_enum_for(:status).with_values(status_values) }
- end
-
- describe 'fingerprint setter' do
- let(:alert) { build(:alert_management_alert) }
-
- subject(:set_fingerprint) { alert.fingerprint = fingerprint }
-
- let(:fingerprint) { 'test' }
-
- it 'sets to the SHA1 of the value' do
- expect { set_fingerprint }
- .to change { alert.fingerprint }
- .from(nil)
- .to(Digest::SHA1.hexdigest(fingerprint))
- end
-
- describe 'testing length of 40' do
- where(:input) do
- [
- 'test',
- 'another test',
- 'a' * 1000,
- 12345
- ]
- end
-
- with_them do
- let(:fingerprint) { input }
-
- it 'sets the fingerprint to 40 chars' do
- set_fingerprint
- expect(alert.fingerprint.size).to eq(40)
- end
- end
- end
-
- context 'blank value given' do
- let(:fingerprint) { '' }
-
- it 'does not set the fingerprint' do
- expect { set_fingerprint }
- .not_to change { alert.fingerprint }
- .from(nil)
- end
- end
end
describe '.for_iid' do
@@ -127,6 +133,18 @@ describe AlertManagement::Alert do
it { is_expected.to match_array(alert_1) }
end
+ describe '.for_fingerprint' do
+ let_it_be(:fingerprint) { SecureRandom.hex }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:alert_1) { create(:alert_management_alert, project: project, fingerprint: fingerprint) }
+ let_it_be(:alert_2) { create(:alert_management_alert, project: project) }
+ let_it_be(:alert_3) { create(:alert_management_alert, fingerprint: fingerprint) }
+
+ subject { described_class.for_fingerprint(project, fingerprint) }
+
+ it { is_expected.to contain_exactly(alert_1) }
+ end
+
describe '.details' do
let(:payload) do
{
@@ -152,4 +170,81 @@ describe AlertManagement::Alert do
)
end
end
+
+ describe '#trigger' do
+ subject { alert.trigger }
+
+ context 'when alert is in triggered state' do
+ let(:alert) { create(:alert_management_alert) }
+
+ it 'does not change the alert status' do
+ expect { subject }.not_to change { alert.reload.status }
+ end
+ end
+
+ context 'when alert not in triggered state' do
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'changes the alert status to triggered' do
+ expect { subject }.to change { alert.triggered? }.to(true)
+ end
+
+ it 'resets ended at' do
+ expect { subject }.to change { alert.reload.ended_at }.to nil
+ end
+ end
+ end
+
+ describe '#acknowledge' do
+ subject { alert.acknowledge }
+
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'changes the alert status to acknowledged' do
+ expect { subject }.to change { alert.acknowledged? }.to(true)
+ end
+
+ it 'resets ended at' do
+ expect { subject }.to change { alert.reload.ended_at }.to nil
+ end
+ end
+
+ describe '#resolve' do
+ let!(:ended_at) { Time.current }
+
+ subject do
+ alert.ended_at = ended_at
+ alert.resolve
+ end
+
+ context 'when alert already resolved' do
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'does not change the alert status' do
+ expect { subject }.not_to change { alert.reload.status }
+ end
+ end
+
+ context 'when alert is not resolved' do
+ let(:alert) { create(:alert_management_alert) }
+
+ it 'changes alert status to "resolved"' do
+ expect { subject }.to change { alert.resolved? }.to(true)
+ end
+ end
+ end
+
+ describe '#ignore' do
+ subject { alert.ignore }
+
+ let(:alert) { create(:alert_management_alert, :resolved) }
+
+ it 'changes the alert status to ignored' do
+ expect { subject }.to change { alert.ignored? }.to(true)
+ end
+
+ it 'resets ended at' do
+ expect { subject }.to change { alert.reload.ended_at }.to nil
+ end
+ end
end
diff --git a/spec/requests/api/graphql/project/alert_management/alerts_spec.rb b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
index 50650d13644..6baa9d4b2f9 100644
--- a/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
+++ b/spec/requests/api/graphql/project/alert_management/alerts_spec.rb
@@ -7,7 +7,7 @@ describe 'getting Alert Management Alerts' do
let_it_be(:payload) { { 'custom' => { 'alert' => 'payload' } } }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:current_user) { create(:user) }
- let_it_be(:alert_1) { create(:alert_management_alert, :all_fields, project: project, severity: :low) }
+ let_it_be(:alert_1) { create(:alert_management_alert, :all_fields, :resolved, project: project, severity: :low) }
let_it_be(:alert_2) { create(:alert_management_alert, :all_fields, project: project, severity: :critical, payload: payload) }
let_it_be(:other_project_alert) { create(:alert_management_alert, :all_fields) }
@@ -49,27 +49,34 @@ describe 'getting Alert Management Alerts' do
end
let(:first_alert) { alerts.first }
+ let(:second_alert) { alerts.second }
it_behaves_like 'a working graphql query'
it { expect(alerts.size).to eq(2) }
- it 'returns the correct properties of the alert' do
+
+ it 'returns the correct properties of the alerts' do
expect(first_alert).to include(
'iid' => alert_2.iid.to_s,
'title' => alert_2.title,
'description' => alert_2.description,
'severity' => alert_2.severity.upcase,
- 'status' => alert_2.status.upcase,
+ 'status' => 'TRIGGERED',
'monitoringTool' => alert_2.monitoring_tool,
'service' => alert_2.service,
'hosts' => alert_2.hosts,
'eventCount' => alert_2.events,
'startedAt' => alert_2.started_at.strftime('%Y-%m-%dT%H:%M:%SZ'),
- 'endedAt' => alert_2.ended_at.strftime('%Y-%m-%dT%H:%M:%SZ'),
+ 'endedAt' => nil,
'details' => { 'custom.alert' => 'payload' },
'createdAt' => alert_2.created_at.strftime('%Y-%m-%dT%H:%M:%SZ'),
'updatedAt' => alert_2.updated_at.strftime('%Y-%m-%dT%H:%M:%SZ')
)
+
+ expect(second_alert).to include(
+ 'status' => 'RESOLVED',
+ 'endedAt' => alert_1.ended_at.strftime('%Y-%m-%dT%H:%M:%SZ')
+ )
end
context 'with iid given' do
diff --git a/spec/services/alert_management/process_prometheus_alert_service_spec.rb b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
new file mode 100644
index 00000000000..73f9f103902
--- /dev/null
+++ b/spec/services/alert_management/process_prometheus_alert_service_spec.rb
@@ -0,0 +1,136 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe AlertManagement::ProcessPrometheusAlertService do
+ let_it_be(:project) { create(:project) }
+
+ describe '#execute' do
+ subject { described_class.new(project, nil, payload).execute }
+
+ context 'when alert payload is valid' do
+ let(:parsed_alert) { Gitlab::Alerting::Alert.new(project: project, payload: payload) }
+ let(:payload) do
+ {
+ 'status' => status,
+ 'labels' => {
+ 'alertname' => 'GitalyFileServerDown',
+ 'channel' => 'gitaly',
+ 'pager' => 'pagerduty',
+ 'severity' => 's1'
+ },
+ 'annotations' => {
+ 'description' => 'Alert description',
+ 'runbook' => 'troubleshooting/gitaly-down.md',
+ 'title' => 'Alert title'
+ },
+ 'startsAt' => '2020-04-27T10:10:22.265949279Z',
+ 'endsAt' => '2020-04-27T10:20:22.265949279Z',
+ 'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1',
+ 'fingerprint' => 'b6ac4d42057c43c1'
+ }
+ end
+
+ context 'when Prometheus alert status is firing' do
+ let(:status) { 'firing' }
+
+ context 'when alert with the same fingerprint already exists' do
+ let!(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+
+ context 'when status can be changed' do
+ it 'changes status to triggered' do
+ expect { subject }.to change { alert.reload.triggered? }.to(true)
+ end
+ end
+
+ context 'when status change did not succeed' do
+ before do
+ allow(AlertManagement::Alert).to receive(:for_fingerprint).and_return([alert])
+ allow(alert).to receive(:trigger).and_return(false)
+ end
+
+ it 'writes a warning to the log' do
+ expect(Gitlab::AppLogger).to receive(:warn).with(
+ message: 'Unable to update AlertManagement::Alert status to triggered',
+ project_id: project.id,
+ alert_id: alert.id
+ )
+
+ subject
+ end
+ end
+
+ it { is_expected.to be_success }
+ end
+
+ context 'when alert does not exist' do
+ context 'when alert can be created' do
+ it 'creates a new alert' do
+ expect { subject }.to change { AlertManagement::Alert.where(project: project).count }.by(1)
+ end
+ end
+
+ context 'when alert cannot be created' do
+ let(:errors) { double(messages: { hosts: ['hosts array is over 255 chars'] })}
+ let(:am_alert) { instance_double(AlertManagement::Alert, save: false, errors: errors) }
+
+ before do
+ allow(AlertManagement::Alert).to receive(:new).and_return(am_alert)
+ end
+
+ it 'writes a warning to the log' do
+ expect(Gitlab::AppLogger).to receive(:warn).with(
+ message: 'Unable to create AlertManagement::Alert',
+ project_id: project.id,
+ alert_errors: { hosts: ['hosts array is over 255 chars'] }
+ )
+
+ subject
+ end
+ end
+
+ it { is_expected.to be_success }
+ end
+ end
+
+ context 'when Prometheus alert status is resolved' do
+ let(:status) { 'resolved' }
+ let!(:alert) { create(:alert_management_alert, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
+
+ context 'when status can be changed' do
+ it 'resolves an existing alert' do
+ expect { subject }.to change { alert.reload.resolved? }.to(true)
+ end
+ end
+
+ context 'when status change did not succeed' do
+ before do
+ allow(AlertManagement::Alert).to receive(:for_fingerprint).and_return([alert])
+ allow(alert).to receive(:resolve).and_return(false)
+ end
+
+ it 'writes a warning to the log' do
+ expect(Gitlab::AppLogger).to receive(:warn).with(
+ message: 'Unable to update AlertManagement::Alert status to resolved',
+ project_id: project.id,
+ alert_id: alert.id
+ )
+
+ subject
+ end
+ end
+
+ it { is_expected.to be_success }
+ end
+ end
+
+ context 'when alert payload is invalid' do
+ let(:payload) { {} }
+
+ it 'responds with bad_request' do
+ expect(subject).to be_error
+ expect(subject.http_status).to eq(:bad_request)
+ end
+ end
+ end
+end
diff --git a/spec/services/alert_management/update_alert_status_service_spec.rb b/spec/services/alert_management/update_alert_status_service_spec.rb
index 325b03840d3..5bdad7a8e19 100644
--- a/spec/services/alert_management/update_alert_status_service_spec.rb
+++ b/spec/services/alert_management/update_alert_status_service_spec.rb
@@ -11,7 +11,7 @@ describe AlertManagement::UpdateAlertStatusService do
let(:new_status) { 'acknowledged' }
it 'updates the status' do
- expect { execute }.to change { alert.status }.to(new_status)
+ expect { execute }.to change { alert.acknowledged? }.to(true)
end
context 'with unknown status' do
diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb
index 22df3b84243..69d555f838d 100644
--- a/spec/services/merge_requests/rebase_service_spec.rb
+++ b/spec/services/merge_requests/rebase_service_spec.rb
@@ -72,12 +72,15 @@ describe MergeRequests::RebaseService do
it_behaves_like 'sequence of failure and success'
context 'when unexpected error occurs' do
+ let(:exception) { RuntimeError.new('Something went wrong') }
+ let(:merge_request_ref) { merge_request.to_reference(full: true) }
+
before do
- allow(repository).to receive(:gitaly_operation_client).and_raise('Something went wrong')
+ allow(repository).to receive(:gitaly_operation_client).and_raise(exception)
end
it 'saves a generic error message' do
- subject.execute(merge_request)
+ service.execute(merge_request)
expect(merge_request.reload.merge_error).to eq(described_class::REBASE_ERROR)
end
@@ -86,6 +89,18 @@ describe MergeRequests::RebaseService do
expect(service.execute(merge_request)).to match(status: :error,
message: described_class::REBASE_ERROR)
end
+
+ it 'logs the error' do
+ expect(service).to receive(:log_error).with(exception: exception, message: described_class::REBASE_ERROR, save_message_on_model: true).and_call_original
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception,
+ class: described_class.to_s,
+ merge_request: merge_request_ref,
+ merge_request_id: merge_request.id,
+ message: described_class::REBASE_ERROR,
+ save_message_on_model: true).and_call_original
+
+ service.execute(merge_request)
+ end
end
context 'with git command failure' do
diff --git a/spec/services/merge_requests/squash_service_spec.rb b/spec/services/merge_requests/squash_service_spec.rb
index cb278eec692..a53314ed737 100644
--- a/spec/services/merge_requests/squash_service_spec.rb
+++ b/spec/services/merge_requests/squash_service_spec.rb
@@ -141,15 +141,14 @@ describe MergeRequests::SquashService do
let(:merge_request) { merge_request_with_only_new_files }
let(:error) { 'A test error' }
- context 'with gitaly enabled' do
+ context 'with an error in Gitaly UserSquash RPC' do
before do
allow(repository.gitaly_operation_client).to receive(:user_squash)
.and_raise(Gitlab::Git::Repository::GitError, error)
end
- it 'logs the stage and output' do
- expect(service).to receive(:log_error).with(log_error)
- expect(service).to receive(:log_error).with(error)
+ it 'logs the error' do
+ expect(service).to receive(:log_error).with(exception: an_instance_of(Gitlab::Git::Repository::GitError), message: 'Failed to squash merge request')
service.execute
end
@@ -158,19 +157,42 @@ describe MergeRequests::SquashService do
expect(service.execute).to match(status: :error, message: a_string_including('squash'))
end
end
+
+ context 'with an error in squash in progress check' do
+ before do
+ allow(repository).to receive(:squash_in_progress?)
+ .and_raise(Gitlab::Git::Repository::GitError, error)
+ end
+
+ it 'logs the stage and output' do
+ expect(service).to receive(:log_error).with(exception: an_instance_of(Gitlab::Git::Repository::GitError), message: 'Failed to check squash in progress')
+
+ service.execute
+ end
+
+ it 'returns an error' do
+ expect(service.execute).to match(status: :error, message: 'An error occurred while checking whether another squash is in progress.')
+ end
+ end
end
context 'when any other exception is thrown' do
let(:merge_request) { merge_request_with_only_new_files }
- let(:error) { 'A test error' }
+ let(:merge_request_ref) { merge_request.to_reference(full: true) }
+ let(:exception) { RuntimeError.new('A test error') }
before do
- allow(merge_request.target_project.repository).to receive(:squash).and_raise(error)
+ allow(merge_request.target_project.repository).to receive(:squash).and_raise(exception)
end
- it 'logs the MR reference and exception' do
- expect(service).to receive(:log_error).with(a_string_including("#{project.full_path}#{merge_request.to_reference}"))
- expect(service).to receive(:log_error).with(error)
+ it 'logs the error' do
+ expect(service).to receive(:log_error).with(exception: exception, message: 'Failed to squash merge request').and_call_original
+ expect(Gitlab::ErrorTracking).to receive(:track_exception).with(exception,
+ class: described_class.to_s,
+ merge_request: merge_request_ref,
+ merge_request_id: merge_request.id,
+ message: 'Failed to squash merge request',
+ save_message_on_model: false).and_call_original
service.execute
end
diff --git a/spec/services/projects/alerting/notify_service_spec.rb b/spec/services/projects/alerting/notify_service_spec.rb
index 8315d2292a0..bfd51874549 100644
--- a/spec/services/projects/alerting/notify_service_spec.rb
+++ b/spec/services/projects/alerting/notify_service_spec.rb
@@ -121,7 +121,7 @@ describe Projects::Alerting::NotifyService do
'hosts' => [],
'payload' => payload_raw,
'severity' => 'critical',
- 'status' => 'triggered',
+ 'status' => AlertManagement::Alert::STATUSES[:triggered],
'events' => 1,
'started_at' => alert.started_at,
'ended_at' => nil
diff --git a/spec/services/projects/prometheus/alerts/notify_service_spec.rb b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
index dce96dda1e3..bfa784cd212 100644
--- a/spec/services/projects/prometheus/alerts/notify_service_spec.rb
+++ b/spec/services/projects/prometheus/alerts/notify_service_spec.rb
@@ -217,6 +217,51 @@ describe Projects::Prometheus::Alerts::NotifyService do
end
end
+ context 'process Alert Management alerts' do
+ let(:process_service) { instance_double(AlertManagement::ProcessPrometheusAlertService) }
+
+ before do
+ create(:prometheus_service, project: project)
+ create(:project_alerting_setting, project: project, token: token)
+ end
+
+ context 'when alert_management_minimal feature enabled' do
+ before do
+ stub_feature_flags(alert_management_minimal: true)
+ end
+
+ context 'with multiple firing alerts and resolving alerts' do
+ let(:payload_raw) do
+ payload_for(firing: [alert_firing, alert_firing], resolved: [alert_resolved])
+ end
+
+ it 'processes Prometheus alerts' do
+ expect(AlertManagement::ProcessPrometheusAlertService)
+ .to receive(:new)
+ .with(project, nil, kind_of(Hash))
+ .exactly(3).times
+ .and_return(process_service)
+ expect(process_service).to receive(:execute).exactly(3).times
+
+ subject
+ end
+ end
+ end
+
+ context 'when alert_management_minimal feature disabled' do
+ before do
+ stub_feature_flags(alert_management_minimal: false)
+ end
+
+ it 'does not process Prometheus alerts' do
+ expect(AlertManagement::ProcessPrometheusAlertService)
+ .not_to receive(:new)
+
+ subject
+ end
+ end
+ end
+
context 'process incident issues' do
before do
create(:prometheus_service, project: project)
@@ -286,6 +331,13 @@ describe Projects::Prometheus::Alerts::NotifyService do
it_behaves_like 'no notifications', http_status: :bad_request
+ it 'does not process Prometheus alerts' do
+ expect(AlertManagement::ProcessPrometheusAlertService)
+ .not_to receive(:new)
+
+ subject
+ end
+
it 'does not process issues' do
expect(IncidentManagement::ProcessPrometheusAlertWorker)
.not_to receive(:perform_async)