提交 f35a7a3b 编写于 作者: G GitLab Bot

Add latest changes from gitlab-org/gitlab@master

上级 896b6851
......@@ -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>
......
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,
},
];
......@@ -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;
......
......@@ -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"
......
<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>
<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>
#import "../fragments/designNote.fragment.graphql"
mutation updateNote($input: UpdateNoteInput!) {
updateNote(input: $input) {
note {
...DesignNote
}
errors
}
}
......@@ -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>
......
......@@ -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
......
......@@ -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.',
);
......
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() {
......
......@@ -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();
......
import vue from './vue';
const languages = [vue];
export default languages;
/*---------------------------------------------------------------------------------------------
* 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,
};
......@@ -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);
}
<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"
......
......@@ -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');
......
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
......@@ -132,6 +134,68 @@ export const timeRangeToUrl = (timeRange, url = window.location.href) => {
return mergeUrlParams(params, toUrl);
};
/**
* 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
......
<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>
<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}')">
......
......@@ -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}">`,
......
......@@ -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"
......
<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>
......
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');
......
......@@ -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"
......
......@@ -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;
......
<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>
<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>
......@@ -94,7 +94,7 @@
}
.ide-pipeline svg {
--svg-status-bg: transparent;
--svg-status-bg: $background;
}
.multi-file-tab-close:hover {
......
......@@ -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;
}
}
}
......@@ -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
......
......@@ -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
......
......@@ -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)
......
......@@ -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
......
......@@ -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
......
# 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
......@@ -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
......
# 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
......@@ -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
......
......@@ -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)
......
......@@ -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
......
......@@ -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
......
......@@ -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')}';
......
......@@ -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
......
......@@ -5,6 +5,6 @@ module ChaosQueue
included do
queue_namespace :chaos
feature_category :chaos_engineering
feature_category_not_owned!
end
end
---
title: 'Resolve Design Comment: Edit Comment text'
merge_request: 30479
author:
type: added
---
title: Use cookies with metadata to prevent reuse as another cookie
merge_request: 31311
author:
type: other
---
title: 'Web IDE: Introduce syntax highlighting for .vue files.'
merge_request: 30986
author:
type: added
---
title: Display expanded dashboard from a panel's "Link to chart" URL
merge_request: 30476
author:
type: added
---
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
---
title: Cleanup background migration for populating user_highest_roles table
merge_request: 31218
author:
type: other
---
title: Improve error handling of squash and rebase
merge_request: 23740
author:
type: other
......@@ -19,7 +19,6 @@
- backup_restore
- behavior_analytics
- billing
- chaos_engineering
- chatops
- cloud_native_installation
- cluster_cost_optimization
......
# 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
# 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
......@@ -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
\.
......@@ -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).
......
......@@ -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
......@@ -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')
......
......@@ -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
......
......@@ -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
......@@ -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 ""
......
......@@ -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
......
......@@ -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
......@@ -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' } }
......
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({
......
......@@ -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', () => {
......
......@@ -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>
......
// 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>"
`;
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);
});
});
});
});
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', () => {
......
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;
});
......
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');
......
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);
});
});
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: ['<!--', '-->'] } }],
]);
});
});
});
......@@ -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, {
......
......@@ -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 });
......
......@@ -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;
......
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);
});
});
......
// 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>
`;
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();
});
});
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', () => {
......
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);
});
});
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);
});
});
......@@ -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
......
......@@ -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) { {} }
......
......@@ -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
......@@ -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, {
......
......@@ -18,6 +18,7 @@ const createTestMr = customConfig => {
isPipelineFailed: false,
isPipelinePassing: false,
isMergeAllowed: true,
isApproved: true,
onlyAllowMergeIfPipelineSucceeds: false,
ffOnlyEnabled: false,
hasCI: false,
......
......@@ -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
......@@ -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(
......
......@@ -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
......
......@@ -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
......@@ -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
......@@ -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
......
# 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
......@@ -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
......
......@@ -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
......
......@@ -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
......
......@@ -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
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册