Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
896b68514b
commit
f35a7a3b8e
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { s__ } from '~/locale';
|
||||
|
||||
export const ALERTS_STATUS = {
|
||||
OPEN: 'open',
|
||||
TRIGGERED: 'triggered',
|
||||
ACKNOWLEDGED: 'acknowledged',
|
||||
RESOLVED: 'resolved',
|
||||
ALL: 'all',
|
||||
};
|
||||
|
||||
export const ALERTS_STATUS_TABS = [
|
||||
{
|
||||
title: s__('AlertManagement|Open'),
|
||||
status: ALERTS_STATUS.OPEN,
|
||||
},
|
||||
{
|
||||
title: s__('AlertManagement|Triggered'),
|
||||
status: ALERTS_STATUS.TRIGGERED,
|
||||
},
|
||||
{
|
||||
title: s__('AlertManagement|Acknowledged'),
|
||||
status: ALERTS_STATUS.ACKNOWLEDGED,
|
||||
},
|
||||
{
|
||||
title: s__('AlertManagement|Resolved'),
|
||||
status: ALERTS_STATUS.RESOLVED,
|
||||
},
|
||||
{
|
||||
title: s__('AlertManagement|All alerts'),
|
||||
status: ALERTS_STATUS.ALL,
|
||||
},
|
||||
];
|
|
@ -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"
|
||||
|
|
|
@ -1,20 +1,42 @@
|
|||
<script>
|
||||
import { ApolloMutation } from 'vue-apollo';
|
||||
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
|
||||
import updateNoteMutation from '../../graphql/mutations/update_note.mutation.graphql';
|
||||
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import DesignReplyForm from './design_reply_form.vue';
|
||||
import { findNoteId } from '../../utils/design_management_utils';
|
||||
import { hasErrors } from '../../utils/cache_update';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatarLink,
|
||||
TimelineEntryItem,
|
||||
TimeAgoTooltip,
|
||||
DesignReplyForm,
|
||||
ApolloMutation,
|
||||
GlIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
note: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
markdownPreviewPath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
noteText: this.note.body,
|
||||
isEditing: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
author() {
|
||||
|
@ -26,12 +48,31 @@ export default {
|
|||
isNoteLinked() {
|
||||
return this.$route.hash === `#note_${this.noteAnchorId}`;
|
||||
},
|
||||
mutationPayload() {
|
||||
return {
|
||||
id: this.note.id,
|
||||
body: this.noteText,
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.isNoteLinked) {
|
||||
this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' });
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
hideForm() {
|
||||
this.isEditing = false;
|
||||
this.noteText = this.note.body;
|
||||
},
|
||||
onDone({ data }) {
|
||||
this.hideForm();
|
||||
if (hasErrors(data.updateNote)) {
|
||||
this.$emit('error', data.errors[0]);
|
||||
}
|
||||
},
|
||||
},
|
||||
updateNoteMutation,
|
||||
};
|
||||
</script>
|
||||
|
||||
|
@ -43,26 +84,65 @@ export default {
|
|||
:img-alt="author.username"
|
||||
:img-size="40"
|
||||
/>
|
||||
<a
|
||||
v-once
|
||||
:href="author.webUrl"
|
||||
class="js-user-link"
|
||||
:data-user-id="author.id"
|
||||
:data-username="author.username"
|
||||
>
|
||||
<span class="note-header-author-name bold">{{ author.name }}</span>
|
||||
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
|
||||
<span class="note-headline-light">@{{ author.username }}</span>
|
||||
</a>
|
||||
<span class="note-headline-light note-headline-meta">
|
||||
<span class="system-note-message"> <slot></slot> </span>
|
||||
<template v-if="note.createdAt">
|
||||
<span class="system-note-separator"></span>
|
||||
<a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
|
||||
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<a
|
||||
v-once
|
||||
:href="author.webUrl"
|
||||
class="js-user-link"
|
||||
:data-user-id="author.id"
|
||||
:data-username="author.username"
|
||||
>
|
||||
<span class="note-header-author-name bold">{{ author.name }}</span>
|
||||
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
|
||||
<span class="note-headline-light">@{{ author.username }}</span>
|
||||
</a>
|
||||
</template>
|
||||
</span>
|
||||
<div class="note-text md" data-qa-selector="note_content" v-html="note.bodyHtml"></div>
|
||||
<span class="note-headline-light note-headline-meta">
|
||||
<span class="system-note-message"> <slot></slot> </span>
|
||||
<template v-if="note.createdAt">
|
||||
<span class="system-note-separator"></span>
|
||||
<a class="note-timestamp system-note-separator" :href="`#note_${noteAnchorId}`">
|
||||
<time-ago-tooltip :time="note.createdAt" tooltip-placement="bottom" />
|
||||
</a>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
v-gl-tooltip
|
||||
type="button"
|
||||
title="Edit comment"
|
||||
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
|
||||
@click="isEditing = true"
|
||||
>
|
||||
<gl-icon name="pencil" class="link-highlight" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="note-text js-note-text md"
|
||||
data-qa-selector="note_content"
|
||||
v-html="note.bodyHtml"
|
||||
></div>
|
||||
<apollo-mutation
|
||||
v-else
|
||||
#default="{ mutate, loading }"
|
||||
:mutation="$options.updateNoteMutation"
|
||||
:variables="{
|
||||
input: mutationPayload,
|
||||
}"
|
||||
@error="$emit('error', $event)"
|
||||
@done="onDone"
|
||||
>
|
||||
<design-reply-form
|
||||
v-model="noteText"
|
||||
:is-saving="loading"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:is-new-comment="false"
|
||||
class="mt-5"
|
||||
@submitForm="mutate"
|
||||
@cancelForm="hideForm"
|
||||
/>
|
||||
</apollo-mutation>
|
||||
</timeline-entry-item>
|
||||
</template>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
import { GlDeprecatedButton, GlModal } from '@gitlab/ui';
|
||||
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
|
||||
import { s__ } from '~/locale';
|
||||
|
||||
export default {
|
||||
name: 'DesignReplyForm',
|
||||
|
@ -23,11 +24,42 @@ export default {
|
|||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
isNewComment: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
formText: this.value,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasValue() {
|
||||
return this.value.trim().length > 0;
|
||||
},
|
||||
modalSettings() {
|
||||
if (this.isNewComment) {
|
||||
return {
|
||||
title: s__('DesignManagement|Cancel comment confirmation'),
|
||||
okTitle: s__('DesignManagement|Discard comment'),
|
||||
cancelTitle: s__('DesignManagement|Keep comment'),
|
||||
content: s__('DesignManagement|Are you sure you want to cancel creating this comment?'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: s__('DesignManagement|Cancel comment update confirmation'),
|
||||
okTitle: s__('DesignManagement|Cancel changes'),
|
||||
cancelTitle: s__('DesignManagement|Keep changes'),
|
||||
content: s__('DesignManagement|Are you sure you want to cancel changes to this comment?'),
|
||||
};
|
||||
},
|
||||
buttonText() {
|
||||
return this.isNewComment
|
||||
? s__('DesignManagement|Comment')
|
||||
: s__('DesignManagement|Save comment');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.textarea.focus();
|
||||
|
@ -37,7 +69,7 @@ export default {
|
|||
if (this.hasValue) this.$emit('submitForm');
|
||||
},
|
||||
cancelComment() {
|
||||
if (this.hasValue) {
|
||||
if (this.hasValue && this.formText !== this.value) {
|
||||
this.$refs.cancelCommentModal.show();
|
||||
} else {
|
||||
this.$emit('cancelForm');
|
||||
|
@ -85,7 +117,7 @@ export default {
|
|||
data-qa-selector="save_comment_button"
|
||||
@click="$emit('submitForm')"
|
||||
>
|
||||
{{ __('Comment') }}
|
||||
{{ buttonText }}
|
||||
</gl-deprecated-button>
|
||||
<gl-deprecated-button ref="cancelButton" @click="cancelComment">{{
|
||||
__('Cancel')
|
||||
|
@ -94,12 +126,12 @@ export default {
|
|||
<gl-modal
|
||||
ref="cancelCommentModal"
|
||||
ok-variant="danger"
|
||||
:title="s__('DesignManagement|Cancel comment confirmation')"
|
||||
:ok-title="s__('DesignManagement|Discard comment')"
|
||||
:cancel-title="s__('DesignManagement|Keep comment')"
|
||||
:title="modalSettings.title"
|
||||
:ok-title="modalSettings.okTitle"
|
||||
:cancel-title="modalSettings.cancelTitle"
|
||||
modal-id="cancel-comment-modal"
|
||||
@ok="$emit('cancelForm')"
|
||||
>{{ s__('DesignManagement|Are you sure you want to cancel creating this comment?') }}
|
||||
>{{ modalSettings.content }}
|
||||
</gl-modal>
|
||||
</form>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
#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.',
|
||||
);
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
|
||||
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
|
||||
import languages from '~/ide/lib/languages';
|
||||
import { defaultEditorOptions } from '~/ide/lib/editor_options';
|
||||
import { registerLanguages } from '~/ide/utils';
|
||||
import { clearDomElement } from './utils';
|
||||
|
||||
export default class Editor {
|
||||
|
@ -17,6 +19,8 @@ export default class Editor {
|
|||
};
|
||||
|
||||
Editor.setupMonacoTheme();
|
||||
|
||||
registerLanguages(...languages);
|
||||
}
|
||||
|
||||
static setupMonacoTheme() {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
import vue from './vue';
|
||||
|
||||
const languages = [vue];
|
||||
|
||||
export default languages;
|
|
@ -0,0 +1,306 @@
|
|||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See https://github.com/microsoft/monaco-languages/blob/master/LICENSE.md
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// Based on handlebars template in https://github.com/microsoft/monaco-languages/blob/master/src/handlebars/handlebars.ts
|
||||
// Look for "vuejs template attributes" in this file for Vue specific syntax.
|
||||
|
||||
import { languages } from 'monaco-editor';
|
||||
|
||||
/* eslint-disable no-useless-escape */
|
||||
/* eslint-disable @gitlab/require-i18n-strings */
|
||||
|
||||
const EMPTY_ELEMENTS = [
|
||||
'area',
|
||||
'base',
|
||||
'br',
|
||||
'col',
|
||||
'embed',
|
||||
'hr',
|
||||
'img',
|
||||
'input',
|
||||
'keygen',
|
||||
'link',
|
||||
'menuitem',
|
||||
'meta',
|
||||
'param',
|
||||
'source',
|
||||
'track',
|
||||
'wbr',
|
||||
];
|
||||
|
||||
const conf = {
|
||||
wordPattern: /(-?\d*\.\d\w*)|([^\`\~\!\@\$\^\&\*\(\)\=\+\[\{\]\}\\\|\;\:\'\"\,\.\<\>\/\s]+)/g,
|
||||
|
||||
comments: {
|
||||
blockComment: ['{{!--', '--}}'],
|
||||
},
|
||||
|
||||
brackets: [['<!--', '-->'], ['<', '>'], ['{{', '}}'], ['{', '}'], ['(', ')']],
|
||||
|
||||
autoClosingPairs: [
|
||||
{ open: '{', close: '}' },
|
||||
{ open: '[', close: ']' },
|
||||
{ open: '(', close: ')' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
],
|
||||
|
||||
surroundingPairs: [
|
||||
{ open: '<', close: '>' },
|
||||
{ open: '"', close: '"' },
|
||||
{ open: "'", close: "'" },
|
||||
],
|
||||
|
||||
onEnterRules: [
|
||||
{
|
||||
beforeText: new RegExp(
|
||||
`<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
|
||||
'i',
|
||||
),
|
||||
afterText: /^<\/(\w[\w\d]*)\s*>$/i,
|
||||
action: { indentAction: languages.IndentAction.IndentOutdent },
|
||||
},
|
||||
{
|
||||
beforeText: new RegExp(
|
||||
`<(?!(?:${EMPTY_ELEMENTS.join('|')}))(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$`,
|
||||
'i',
|
||||
),
|
||||
action: { indentAction: languages.IndentAction.Indent },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const language = {
|
||||
defaultToken: '',
|
||||
tokenPostfix: '',
|
||||
// ignoreCase: true,
|
||||
|
||||
// The main tokenizer for our languages
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.root' }],
|
||||
[/<!DOCTYPE/, 'metatag.html', '@doctype'],
|
||||
[/<!--/, 'comment.html', '@comment'],
|
||||
[/(<)([\w]+)(\/>)/, ['delimiter.html', 'tag.html', 'delimiter.html']],
|
||||
[/(<)(script)/, ['delimiter.html', { token: 'tag.html', next: '@script' }]],
|
||||
[/(<)(style)/, ['delimiter.html', { token: 'tag.html', next: '@style' }]],
|
||||
[/(<)([:\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
|
||||
[/(<\/)([\w]+)/, ['delimiter.html', { token: 'tag.html', next: '@otherTag' }]],
|
||||
[/</, 'delimiter.html'],
|
||||
[/\{/, 'delimiter.html'],
|
||||
[/[^<{]+/], // text
|
||||
],
|
||||
|
||||
doctype: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
|
||||
[/[^>]+/, 'metatag.content.html'],
|
||||
[/>/, 'metatag.html', '@pop'],
|
||||
],
|
||||
|
||||
comment: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.comment' }],
|
||||
[/-->/, 'comment.html', '@pop'],
|
||||
[/[^-]+/, 'comment.content.html'],
|
||||
[/./, 'comment.content.html'],
|
||||
],
|
||||
|
||||
otherTag: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.otherTag' }],
|
||||
[/\/?>/, 'delimiter.html', '@pop'],
|
||||
|
||||
// -- BEGIN vuejs template attributes
|
||||
[/(v-|@|:)[\w\-\.\:\[\]]+="([^"]*)"/, 'variable'],
|
||||
[/(v-|@|:)[\w\-\.\:\[\]]+='([^']*)'/, 'variable'],
|
||||
|
||||
[/"([^"]*)"/, 'attribute.value'],
|
||||
[/'([^']*)'/, 'attribute.value'],
|
||||
|
||||
[/[\w\-\.\:\[\]]+/, 'attribute.name'],
|
||||
// -- END vuejs template attributes
|
||||
|
||||
[/=/, 'delimiter'],
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
],
|
||||
|
||||
// -- BEGIN <script> tags handling
|
||||
|
||||
// After <script
|
||||
script: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.script' }],
|
||||
[/type/, 'attribute.name', '@scriptAfterType'],
|
||||
[/"([^"]*)"/, 'attribute.value'],
|
||||
[/'([^']*)'/, 'attribute.value'],
|
||||
[/[\w\-]+/, 'attribute.name'],
|
||||
[/=/, 'delimiter'],
|
||||
[
|
||||
/>/,
|
||||
{
|
||||
token: 'delimiter.html',
|
||||
next: '@scriptEmbedded.text/javascript',
|
||||
nextEmbedded: 'text/javascript',
|
||||
},
|
||||
],
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[
|
||||
/(<\/)(script\s*)(>)/,
|
||||
['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
|
||||
],
|
||||
],
|
||||
|
||||
// After <script ... type
|
||||
scriptAfterType: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterType' }],
|
||||
[/=/, 'delimiter', '@scriptAfterTypeEquals'],
|
||||
[
|
||||
/>/,
|
||||
{
|
||||
token: 'delimiter.html',
|
||||
next: '@scriptEmbedded.text/javascript',
|
||||
nextEmbedded: 'text/javascript',
|
||||
},
|
||||
], // cover invalid e.g. <script type>
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
|
||||
],
|
||||
|
||||
// After <script ... type =
|
||||
scriptAfterTypeEquals: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptAfterTypeEquals' }],
|
||||
[/"([^"]*)"/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
|
||||
[/'([^']*)'/, { token: 'attribute.value', switchTo: '@scriptWithCustomType.$1' }],
|
||||
[
|
||||
/>/,
|
||||
{
|
||||
token: 'delimiter.html',
|
||||
next: '@scriptEmbedded.text/javascript',
|
||||
nextEmbedded: 'text/javascript',
|
||||
},
|
||||
], // cover invalid e.g. <script type=>
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
|
||||
],
|
||||
|
||||
// After <script ... type = $S2
|
||||
scriptWithCustomType: [
|
||||
[
|
||||
/\{\{/,
|
||||
{ token: '@rematch', switchTo: '@handlebarsInSimpleState.scriptWithCustomType.$S2' },
|
||||
],
|
||||
[/>/, { token: 'delimiter.html', next: '@scriptEmbedded.$S2', nextEmbedded: '$S2' }],
|
||||
[/"([^"]*)"/, 'attribute.value'],
|
||||
[/'([^']*)'/, 'attribute.value'],
|
||||
[/[\w\-]+/, 'attribute.name'],
|
||||
[/=/, 'delimiter'],
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[/<\/script\s*>/, { token: '@rematch', next: '@pop' }],
|
||||
],
|
||||
|
||||
scriptEmbedded: [
|
||||
[
|
||||
/\{\{/,
|
||||
{
|
||||
token: '@rematch',
|
||||
switchTo: '@handlebarsInEmbeddedState.scriptEmbedded.$S2',
|
||||
nextEmbedded: '@pop',
|
||||
},
|
||||
],
|
||||
[/<\/script/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
|
||||
],
|
||||
|
||||
// -- END <script> tags handling
|
||||
|
||||
// -- BEGIN <style> tags handling
|
||||
|
||||
// After <style
|
||||
style: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.style' }],
|
||||
[/type/, 'attribute.name', '@styleAfterType'],
|
||||
[/"([^"]*)"/, 'attribute.value'],
|
||||
[/'([^']*)'/, 'attribute.value'],
|
||||
[/[\w\-]+/, 'attribute.name'],
|
||||
[/=/, 'delimiter'],
|
||||
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }],
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[
|
||||
/(<\/)(style\s*)(>)/,
|
||||
['delimiter.html', 'tag.html', { token: 'delimiter.html', next: '@pop' }],
|
||||
],
|
||||
],
|
||||
|
||||
// After <style ... type
|
||||
styleAfterType: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterType' }],
|
||||
[/=/, 'delimiter', '@styleAfterTypeEquals'],
|
||||
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type>
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
|
||||
],
|
||||
|
||||
// After <style ... type =
|
||||
styleAfterTypeEquals: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleAfterTypeEquals' }],
|
||||
[/"([^"]*)"/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
|
||||
[/'([^']*)'/, { token: 'attribute.value', switchTo: '@styleWithCustomType.$1' }],
|
||||
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.text/css', nextEmbedded: 'text/css' }], // cover invalid e.g. <style type=>
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
|
||||
],
|
||||
|
||||
// After <style ... type = $S2
|
||||
styleWithCustomType: [
|
||||
[/\{\{/, { token: '@rematch', switchTo: '@handlebarsInSimpleState.styleWithCustomType.$S2' }],
|
||||
[/>/, { token: 'delimiter.html', next: '@styleEmbedded.$S2', nextEmbedded: '$S2' }],
|
||||
[/"([^"]*)"/, 'attribute.value'],
|
||||
[/'([^']*)'/, 'attribute.value'],
|
||||
[/[\w\-]+/, 'attribute.name'],
|
||||
[/=/, 'delimiter'],
|
||||
[/[ \t\r\n]+/], // whitespace
|
||||
[/<\/style\s*>/, { token: '@rematch', next: '@pop' }],
|
||||
],
|
||||
|
||||
styleEmbedded: [
|
||||
[
|
||||
/\{\{/,
|
||||
{
|
||||
token: '@rematch',
|
||||
switchTo: '@handlebarsInEmbeddedState.styleEmbedded.$S2',
|
||||
nextEmbedded: '@pop',
|
||||
},
|
||||
],
|
||||
[/<\/style/, { token: '@rematch', next: '@pop', nextEmbedded: '@pop' }],
|
||||
],
|
||||
|
||||
// -- END <style> tags handling
|
||||
|
||||
handlebarsInSimpleState: [
|
||||
[/\{\{\{?/, 'delimiter.handlebars'],
|
||||
[/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3' }],
|
||||
{ include: 'handlebarsRoot' },
|
||||
],
|
||||
|
||||
handlebarsInEmbeddedState: [
|
||||
[/\{\{\{?/, 'delimiter.handlebars'],
|
||||
[/\}\}\}?/, { token: 'delimiter.handlebars', switchTo: '@$S2.$S3', nextEmbedded: '$S3' }],
|
||||
{ include: 'handlebarsRoot' },
|
||||
],
|
||||
|
||||
handlebarsRoot: [
|
||||
[/"[^"]*"/, 'string.handlebars'],
|
||||
[/[#/][^\s}]+/, 'keyword.helper.handlebars'],
|
||||
[/else\b/, 'keyword.helper.handlebars'],
|
||||
[/[\s]+/],
|
||||
[/[^}]/, 'variable.parameter.handlebars'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
id: 'vue',
|
||||
extensions: ['.vue'],
|
||||
aliases: ['Vue', 'vue'],
|
||||
mimetypes: ['text/x-vue-template'],
|
||||
conf,
|
||||
language,
|
||||
};
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { debounce, pickBy } from 'lodash';
|
||||
import { debounce } from 'lodash';
|
||||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
import VueDraggable from 'vuedraggable';
|
||||
import {
|
||||
|
@ -32,7 +32,13 @@ import GroupEmptyState from './group_empty_state.vue';
|
|||
import DashboardsDropdown from './dashboards_dropdown.vue';
|
||||
|
||||
import TrackEventDirective from '~/vue_shared/directives/track_event';
|
||||
import { getAddMetricTrackingOptions, timeRangeToUrl, timeRangeFromUrl } from '../utils';
|
||||
import {
|
||||
getAddMetricTrackingOptions,
|
||||
timeRangeToUrl,
|
||||
timeRangeFromUrl,
|
||||
panelToUrl,
|
||||
expandedPanelPayloadFromUrl,
|
||||
} from '../utils';
|
||||
import { metricStates } from '../constants';
|
||||
import { defaultTimeRange, timeRanges } from '~/vue_shared/constants';
|
||||
|
||||
|
@ -238,6 +244,23 @@ export default {
|
|||
return !this.environmentsLoading && this.filteredEnvironments.length === 0;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
dashboard(newDashboard) {
|
||||
try {
|
||||
const expandedPanel = expandedPanelPayloadFromUrl(newDashboard);
|
||||
if (expandedPanel) {
|
||||
this.setExpandedPanel(expandedPanel);
|
||||
}
|
||||
} catch {
|
||||
createFlash(
|
||||
s__(
|
||||
'Metrics|Link contains invalid chart information, please verify the link to see the expanded panel.',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.setInitialState({
|
||||
metricsEndpoint: this.metricsEndpoint,
|
||||
|
@ -299,15 +322,9 @@ export default {
|
|||
// As a fallback, switch to default time range instead
|
||||
this.selectedTimeRange = defaultTimeRange;
|
||||
},
|
||||
|
||||
generatePanelLink(group, graphData) {
|
||||
if (!group || !graphData) {
|
||||
return null;
|
||||
}
|
||||
const dashboard = this.currentDashboard || this.firstDashboard.path;
|
||||
const { y_label, title } = graphData;
|
||||
const params = pickBy({ dashboard, group, title, y_label }, value => value != null);
|
||||
return mergeUrlParams(params, window.location.href);
|
||||
generatePanelUrl(groupKey, panel) {
|
||||
const dashboardPath = this.currentDashboard || this.firstDashboard.path;
|
||||
return panelToUrl(dashboardPath, groupKey, panel);
|
||||
},
|
||||
hideAddMetricModal() {
|
||||
this.$refs.addMetricModal.hide();
|
||||
|
@ -564,7 +581,7 @@ export default {
|
|||
v-show="expandedPanel.panel"
|
||||
ref="expandedPanel"
|
||||
:settings-path="settingsPath"
|
||||
:clipboard-text="generatePanelLink(expandedPanel.group, expandedPanel.panel)"
|
||||
:clipboard-text="generatePanelUrl(expandedPanel.group, expandedPanel.panel)"
|
||||
:graph-data="expandedPanel.panel"
|
||||
:alerts-endpoint="alertsEndpoint"
|
||||
:height="600"
|
||||
|
@ -623,7 +640,7 @@ export default {
|
|||
|
||||
<dashboard-panel
|
||||
:settings-path="settingsPath"
|
||||
:clipboard-text="generatePanelLink(groupData.group, graphData)"
|
||||
:clipboard-text="generatePanelUrl(groupData.group, graphData)"
|
||||
:graph-data="graphData"
|
||||
:alerts-endpoint="alertsEndpoint"
|
||||
:prometheus-alerts-available="prometheusAlertsAvailable"
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { pickBy } from 'lodash';
|
||||
import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
|
||||
import {
|
||||
timeRangeParamNames,
|
||||
|
@ -28,7 +29,6 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
|
|||
);
|
||||
};
|
||||
|
||||
/* eslint-disable @gitlab/require-i18n-strings */
|
||||
/**
|
||||
* Checks that element that triggered event is located on cluster health check dashboard
|
||||
* @param {HTMLElement} element to check against
|
||||
|
@ -36,6 +36,7 @@ export const graphDataValidatorForValues = (isValues, graphData) => {
|
|||
*/
|
||||
const isClusterHealthBoard = () => (document.body.dataset.page || '').includes(':clusters:show');
|
||||
|
||||
/* eslint-disable @gitlab/require-i18n-strings */
|
||||
/**
|
||||
* Tracks snowplow event when user generates link to metric chart
|
||||
* @param {String} chart link that will be sent as a property for the event
|
||||
|
@ -71,6 +72,7 @@ export const downloadCSVOptions = title => {
|
|||
|
||||
return { category, action, label: 'Chart title', property: title };
|
||||
};
|
||||
/* eslint-enable @gitlab/require-i18n-strings */
|
||||
|
||||
/**
|
||||
* Generate options for snowplow to track adding a new metric via the dashboard
|
||||
|
@ -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
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
<script>
|
||||
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MarkdownFieldView,
|
||||
},
|
||||
props: {
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<markdown-field-view class="snippet-description" data-qa-selector="snippet_description_field">
|
||||
<div class="md js-snippet-description" v-html="description"></div>
|
||||
</markdown-field-view>
|
||||
</template>
|
|
@ -1,11 +1,14 @@
|
|||
<script>
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import { GlSprintf } from '@gitlab/ui';
|
||||
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import SnippetDescription from './snippet_description_view.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TimeAgoTooltip,
|
||||
GlSprintf,
|
||||
SnippetDescription,
|
||||
},
|
||||
props: {
|
||||
snippet: {
|
||||
|
@ -20,13 +23,8 @@ export default {
|
|||
<h2 class="snippet-title prepend-top-0 mb-3" data-qa-selector="snippet_title">
|
||||
{{ snippet.title }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="snippet.description"
|
||||
class="description"
|
||||
data-qa-selector="snippet_description_field"
|
||||
>
|
||||
<div class="md js-snippet-description" v-html="snippet.descriptionHtml"></div>
|
||||
</div>
|
||||
|
||||
<snippet-description v-if="snippet.description" :description="snippet.descriptionHtml" />
|
||||
|
||||
<small v-if="snippet.updatedAt !== snippet.createdAt" class="edited-text">
|
||||
<gl-sprintf :message="__('Edited %{timeago}')">
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { isEmpty } from 'lodash';
|
||||
import { GlIcon, GlDeprecatedButton } from '@gitlab/ui';
|
||||
import { GlIcon, GlDeprecatedButton, GlSprintf, GlLink } from '@gitlab/ui';
|
||||
import successSvg from 'icons/_icon_status_success.svg';
|
||||
import warningSvg from 'icons/_icon_status_warning.svg';
|
||||
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
|
||||
|
@ -26,6 +26,8 @@ export default {
|
|||
CommitEdit,
|
||||
CommitMessageDropdown,
|
||||
GlIcon,
|
||||
GlSprintf,
|
||||
GlLink,
|
||||
GlDeprecatedButton,
|
||||
MergeImmediatelyConfirmationDialog: () =>
|
||||
import(
|
||||
|
@ -56,7 +58,7 @@ export default {
|
|||
status() {
|
||||
const { pipeline, isPipelineFailed, hasCI, ciStatus } = this.mr;
|
||||
|
||||
if (hasCI && !ciStatus) {
|
||||
if ((hasCI && !ciStatus) || this.hasPipelineMustSucceedConflict) {
|
||||
return 'failed';
|
||||
} else if (this.isAutoMergeAvailable) {
|
||||
return 'pending';
|
||||
|
@ -97,6 +99,9 @@ export default {
|
|||
|
||||
return __('Merge');
|
||||
},
|
||||
hasPipelineMustSucceedConflict() {
|
||||
return !this.mr.hasCI && this.mr.onlyAllowMergeIfPipelineSucceeds;
|
||||
},
|
||||
isRemoveSourceBranchButtonDisabled() {
|
||||
return this.isMergeButtonDisabled;
|
||||
},
|
||||
|
@ -343,9 +348,19 @@ export default {
|
|||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="bold js-resolve-mr-widget-items-message">
|
||||
{{ mergeDisabledText }}
|
||||
</span>
|
||||
<div class="bold js-resolve-mr-widget-items-message">
|
||||
<gl-sprintf
|
||||
v-if="hasPipelineMustSucceedConflict"
|
||||
:message="pipelineMustSucceedConflictText"
|
||||
>
|
||||
<template #link="{ content }">
|
||||
<gl-link :href="mr.pipelineMustSucceedDocsPath" target="_blank">
|
||||
{{ content }}
|
||||
</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
<gl-sprintf v-else :message="mergeDisabledText" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,9 @@
|
|||
import { __ } from '~/locale';
|
||||
|
||||
export const MERGE_DISABLED_TEXT = __('You can only merge once the items above are resolved.');
|
||||
export const PIPELINE_MUST_SUCCEED_CONFLICT_TEXT = __(
|
||||
'Pipelines must succeed for merge requests to be eligible to merge. Please enable pipelines for this project to continue. For more information, see the %{linkStart}documentation.%{linkEnd}',
|
||||
);
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
|
@ -16,6 +19,9 @@ export default {
|
|||
mergeDisabledText() {
|
||||
return MERGE_DISABLED_TEXT;
|
||||
},
|
||||
pipelineMustSucceedConflictText() {
|
||||
return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT;
|
||||
},
|
||||
autoMergeText() {
|
||||
// MWPS is currently the only auto merge strategy available in CE
|
||||
return __('Merge when pipeline succeeds');
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
<script>
|
||||
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
|
||||
import ViewerMixin from './mixins';
|
||||
import { handleBlobRichViewer } from '~/blob/viewer';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MarkdownFieldView,
|
||||
},
|
||||
mixins: [ViewerMixin],
|
||||
mounted() {
|
||||
handleBlobRichViewer(this.$refs.content, this.type);
|
||||
|
@ -10,5 +14,5 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<template>
|
||||
<div ref="content" v-html="content"></div>
|
||||
<markdown-field-view ref="content" v-html="content" />
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
<script>
|
||||
import $ from 'jquery';
|
||||
import '~/behaviors/markdown/render_gfm';
|
||||
|
||||
export default {
|
||||
mounted() {
|
||||
this.renderGFM();
|
||||
},
|
||||
methods: {
|
||||
renderGFM() {
|
||||
$(this.$el).renderGFM();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div><slot></slot></div>
|
||||
</template>
|
|
@ -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
|
||||
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module AlertManagement
|
||||
class ProcessPrometheusAlertService < BaseService
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
def execute
|
||||
return bad_request unless parsed_alert.valid?
|
||||
|
||||
process_alert_management_alert
|
||||
|
||||
ServiceResponse.success
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :firing?, :resolved?, :gitlab_fingerprint, :ends_at, to: :parsed_alert
|
||||
|
||||
def parsed_alert
|
||||
strong_memoize(:parsed_alert) do
|
||||
Gitlab::Alerting::Alert.new(project: project, payload: params)
|
||||
end
|
||||
end
|
||||
|
||||
def process_alert_management_alert
|
||||
process_firing_alert_management_alert if firing?
|
||||
process_resolved_alert_management_alert if resolved?
|
||||
end
|
||||
|
||||
def process_firing_alert_management_alert
|
||||
if am_alert.present?
|
||||
reset_alert_management_alert_status
|
||||
else
|
||||
create_alert_management_alert
|
||||
end
|
||||
end
|
||||
|
||||
def reset_alert_management_alert_status
|
||||
return if am_alert.trigger
|
||||
|
||||
logger.warn(
|
||||
message: 'Unable to update AlertManagement::Alert status to triggered',
|
||||
project_id: project.id,
|
||||
alert_id: am_alert.id
|
||||
)
|
||||
end
|
||||
|
||||
def create_alert_management_alert
|
||||
am_alert = AlertManagement::Alert.new(am_alert_params.merge(ended_at: nil))
|
||||
return if am_alert.save
|
||||
|
||||
logger.warn(
|
||||
message: 'Unable to create AlertManagement::Alert',
|
||||
project_id: project.id,
|
||||
alert_errors: am_alert.errors.messages
|
||||
)
|
||||
end
|
||||
|
||||
def am_alert_params
|
||||
Gitlab::AlertManagement::AlertParams.from_prometheus_alert(project: project, parsed_alert: parsed_alert)
|
||||
end
|
||||
|
||||
def process_resolved_alert_management_alert
|
||||
return if am_alert.blank?
|
||||
return if am_alert.resolve(ends_at)
|
||||
|
||||
logger.warn(
|
||||
message: 'Unable to update AlertManagement::Alert status to resolved',
|
||||
project_id: project.id,
|
||||
alert_id: am_alert.id
|
||||
)
|
||||
end
|
||||
|
||||
def logger
|
||||
@logger ||= Gitlab::AppLogger
|
||||
end
|
||||
|
||||
def am_alert
|
||||
@am_alert ||= AlertManagement::Alert.for_fingerprint(project, gitlab_fingerprint).first
|
||||
end
|
||||
|
||||
def bad_request
|
||||
ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -1,10 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Git
|
||||
module Logger
|
||||
def log_error(message, save_message_on_model: false)
|
||||
Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}")
|
||||
merge_request.update(merge_error: message) if save_message_on_model
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: 'Resolve Design Comment: Edit Comment text'
|
||||
merge_request: 30479
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Use cookies with metadata to prevent reuse as another cookie
|
||||
merge_request: 31311
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: 'Web IDE: Introduce syntax highlighting for .vue files.'
|
||||
merge_request: 30986
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Display expanded dashboard from a panel's "Link to chart" URL
|
||||
merge_request: 30476
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
title:
|
||||
Add clear explanation to the MR widget when no CI is available and Pipeline
|
||||
must succeed option is activated
|
||||
merge_request: 31112
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Cleanup background migration for populating user_highest_roles table
|
||||
merge_request: 31218
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
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
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
# Be sure to restart your server when you modify this file.
|
||||
|
||||
Rails.application.config.action_dispatch.use_cookies_with_metadata = false
|
||||
Rails.application.config.action_dispatch.use_cookies_with_metadata = true
|
||||
Rails.application.config.action_dispatch.cookies_serializer = :hybrid
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CleanupUserHighestRolesPopulation < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
INDEX_NAME = 'index_for_migrating_user_highest_roles_table'
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
Gitlab::BackgroundMigration.steal('PopulateUserHighestRolesTable')
|
||||
|
||||
remove_concurrent_index(:users, :id, name: INDEX_NAME)
|
||||
end
|
||||
|
||||
def down
|
||||
add_concurrent_index(:users,
|
||||
:id,
|
||||
where: "state = 'active' AND user_type IS NULL AND bot_type IS NULL AND ghost IS NOT TRUE",
|
||||
name: INDEX_NAME)
|
||||
end
|
||||
end
|
|
@ -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
|
||||
memory_rss = System.memory_usage
|
||||
metrics[:memory_bytes].set(labels, memory_rss)
|
||||
metrics[:process_resident_memory_bytes].set(labels, memory_rss)
|
||||
|
||||
metrics[:memory_bytes].set(labels, memory_usage)
|
||||
metrics[:process_resident_memory_bytes].set(labels, memory_usage)
|
||||
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+)/)
|
||||
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/*'
|
||||
|
||||
if match && match[1]
|
||||
mem = match[1].to_f * 1024
|
||||
end
|
||||
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
|
||||
|
||||
mem
|
||||
end
|
||||
# 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
|
||||
|
||||
def self.file_descriptor_count
|
||||
Dir.glob('/proc/self/fd/*').length
|
||||
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.max_open_file_descriptors
|
||||
match = File.read('/proc/self/limits').match(/Max open files\s*(\d+)/)
|
||||
def self.file_descriptor_count
|
||||
Dir.glob(PROC_FD_GLOB).length
|
||||
end
|
||||
|
||||
return unless match && match[1]
|
||||
|
||||
match[1].to_i
|
||||
end
|
||||
else
|
||||
def self.memory_usage
|
||||
0.0
|
||||
end
|
||||
|
||||
def self.file_descriptor_count
|
||||
0
|
||||
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' } }
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import { GlEmptyState, GlTable, GlAlert, GlLoadingIcon, GlNewDropdown, GlIcon } from '@gitlab/ui';
|
||||
import {
|
||||
GlEmptyState,
|
||||
GlTable,
|
||||
GlAlert,
|
||||
GlLoadingIcon,
|
||||
GlNewDropdown,
|
||||
GlBadge,
|
||||
GlIcon,
|
||||
GlTab,
|
||||
} from '@gitlab/ui';
|
||||
import AlertManagementList from '~/alert_management/components/alert_management_list.vue';
|
||||
import { ALERTS_STATUS_TABS } from '../../../../app/assets/javascripts/alert_management/constants';
|
||||
|
||||
import mockAlerts from '../mocks/alerts.json';
|
||||
|
||||
|
@ -12,6 +22,8 @@ describe('AlertManagementList', () => {
|
|||
const findAlert = () => wrapper.find(GlAlert);
|
||||
const findLoader = () => wrapper.find(GlLoadingIcon);
|
||||
const findStatusDropdown = () => wrapper.find(GlNewDropdown);
|
||||
const findStatusFilterTabs = () => wrapper.findAll(GlTab);
|
||||
const findNumberOfAlertsBadge = () => wrapper.findAll(GlBadge);
|
||||
|
||||
function mountComponent({
|
||||
props = {
|
||||
|
@ -20,6 +32,8 @@ describe('AlertManagementList', () => {
|
|||
},
|
||||
data = {},
|
||||
loading = false,
|
||||
alertListStatusFilteringEnabled = false,
|
||||
stubs = {},
|
||||
} = {}) {
|
||||
wrapper = mount(AlertManagementList, {
|
||||
propsData: {
|
||||
|
@ -28,6 +42,9 @@ describe('AlertManagementList', () => {
|
|||
emptyAlertSvgPath: 'illustration/path',
|
||||
...props,
|
||||
},
|
||||
provide: {
|
||||
glFeatures: { alertListStatusFilteringEnabled },
|
||||
},
|
||||
data() {
|
||||
return data;
|
||||
},
|
||||
|
@ -40,6 +57,7 @@ describe('AlertManagementList', () => {
|
|||
},
|
||||
},
|
||||
},
|
||||
stubs,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -59,6 +77,56 @@ describe('AlertManagementList', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Status Filter Tabs', () => {
|
||||
describe('alertListStatusFilteringEnabled feature flag enabled', () => {
|
||||
beforeEach(() => {
|
||||
mountComponent({
|
||||
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
|
||||
data: { alerts: mockAlerts },
|
||||
loading: false,
|
||||
alertListStatusFilteringEnabled: true,
|
||||
stubs: {
|
||||
GlTab: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should display filter tabs for all statuses', () => {
|
||||
const tabs = findStatusFilterTabs().wrappers;
|
||||
tabs.forEach((tab, i) => {
|
||||
expect(tab.text()).toContain(ALERTS_STATUS_TABS[i].title);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have number of items badge along with status tab', () => {
|
||||
expect(findNumberOfAlertsBadge().length).toEqual(ALERTS_STATUS_TABS.length);
|
||||
expect(
|
||||
findNumberOfAlertsBadge()
|
||||
.at(0)
|
||||
.text(),
|
||||
).toEqual(`${mockAlerts.length}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alertListStatusFilteringEnabled feature flag disabled', () => {
|
||||
beforeEach(() => {
|
||||
mountComponent({
|
||||
props: { alertManagementEnabled: true, userCanEnableAlertManagement: true },
|
||||
data: { alerts: mockAlerts },
|
||||
loading: false,
|
||||
alertListStatusFilteringEnabled: false,
|
||||
stubs: {
|
||||
GlTab: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should NOT display tabs', () => {
|
||||
expect(findStatusFilterTabs()).not.toExist();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Alerts table', () => {
|
||||
it('loading state', () => {
|
||||
mountComponent({
|
||||
|
|
|
@ -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"
|
||||
<div
|
||||
class="d-flex justify-content-between"
|
||||
>
|
||||
<span
|
||||
class="note-header-author-name bold"
|
||||
<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>
|
||||
|
||||
<button
|
||||
class="note-action-button js-note-edit btn btn-transparent qa-note-edit-button"
|
||||
title="Edit comment"
|
||||
type="button"
|
||||
>
|
||||
|
||||
</span>
|
||||
|
||||
<!---->
|
||||
|
||||
<span
|
||||
class="note-headline-light"
|
||||
>
|
||||
@
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<span
|
||||
class="note-headline-light note-headline-meta"
|
||||
>
|
||||
<span
|
||||
class="system-note-message"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="system-note-separator"
|
||||
/>
|
||||
|
||||
<a
|
||||
class="note-timestamp system-note-separator"
|
||||
href="#note_undefined"
|
||||
>
|
||||
<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>
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = `
|
||||
"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
|
||||
<!---->
|
||||
Comment
|
||||
</button>"
|
||||
`;
|
||||
|
||||
exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = `
|
||||
"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\">
|
||||
<!---->
|
||||
Save comment
|
||||
</button>"
|
||||
`;
|
|
@ -1,29 +1,54 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { ApolloMutation } from 'vue-apollo';
|
||||
import DesignNote from '~/design_management/components/design_notes/design_note.vue';
|
||||
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
|
||||
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
|
||||
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
|
||||
|
||||
const scrollIntoViewMock = jest.fn();
|
||||
const note = {
|
||||
id: 'gid://gitlab/DiffNote/123',
|
||||
author: {
|
||||
id: 'author-id',
|
||||
},
|
||||
body: 'test',
|
||||
};
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
|
||||
|
||||
const $route = {
|
||||
hash: '#note_123',
|
||||
};
|
||||
|
||||
const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } });
|
||||
|
||||
describe('Design note component', () => {
|
||||
let wrapper;
|
||||
|
||||
const findUserAvatar = () => wrapper.find(UserAvatarLink);
|
||||
const findUserLink = () => wrapper.find('.js-user-link');
|
||||
const findReplyForm = () => wrapper.find(DesignReplyForm);
|
||||
const findEditButton = () => wrapper.find('.js-note-edit');
|
||||
const findNoteContent = () => wrapper.find('.js-note-text');
|
||||
|
||||
function createComponent(props = {}) {
|
||||
function createComponent(props = {}, data = { isEditing: false }) {
|
||||
wrapper = shallowMount(DesignNote, {
|
||||
propsData: {
|
||||
note: {},
|
||||
...props,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
...data,
|
||||
};
|
||||
},
|
||||
mocks: {
|
||||
$route,
|
||||
$apollo: {
|
||||
mutate,
|
||||
},
|
||||
},
|
||||
stubs: {
|
||||
ApolloMutation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -34,13 +59,7 @@ describe('Design note component', () => {
|
|||
|
||||
it('should match the snapshot', () => {
|
||||
createComponent({
|
||||
note: {
|
||||
id: '1',
|
||||
createdAt: '2019-07-26T15:02:20Z',
|
||||
author: {
|
||||
id: 'author-id',
|
||||
},
|
||||
},
|
||||
note,
|
||||
});
|
||||
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
|
@ -48,12 +67,7 @@ describe('Design note component', () => {
|
|||
|
||||
it('should render an author', () => {
|
||||
createComponent({
|
||||
note: {
|
||||
id: '1',
|
||||
author: {
|
||||
id: 'author-id',
|
||||
},
|
||||
},
|
||||
note,
|
||||
});
|
||||
|
||||
expect(findUserAvatar().exists()).toBe(true);
|
||||
|
@ -63,11 +77,8 @@ describe('Design note component', () => {
|
|||
it('should render a time ago tooltip if note has createdAt property', () => {
|
||||
createComponent({
|
||||
note: {
|
||||
id: '1',
|
||||
...note,
|
||||
createdAt: '2019-07-26T15:02:20Z',
|
||||
author: {
|
||||
id: 'author-id',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -76,14 +87,61 @@ describe('Design note component', () => {
|
|||
|
||||
it('should trigger a scrollIntoView method', () => {
|
||||
createComponent({
|
||||
note: {
|
||||
id: 'gid://gitlab/DiffNote/123',
|
||||
author: {
|
||||
id: 'author-id',
|
||||
},
|
||||
},
|
||||
note,
|
||||
});
|
||||
|
||||
expect(scrollIntoViewMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should open an edit form on edit button click', () => {
|
||||
createComponent({
|
||||
note,
|
||||
});
|
||||
|
||||
findEditButton().trigger('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findReplyForm().exists()).toBe(true);
|
||||
expect(findNoteContent().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when edit form is rendered', () => {
|
||||
beforeEach(() => {
|
||||
createComponent(
|
||||
{
|
||||
note,
|
||||
},
|
||||
{ isEditing: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render note content and should render reply form', () => {
|
||||
expect(findNoteContent().exists()).toBe(false);
|
||||
expect(findReplyForm().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the form on hideForm event', () => {
|
||||
findReplyForm().vm.$emit('cancelForm');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(findReplyForm().exists()).toBe(false);
|
||||
expect(findNoteContent().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls a mutation on submitForm event and hides a form', () => {
|
||||
findReplyForm().vm.$emit('submitForm');
|
||||
expect(mutate).toHaveBeenCalled();
|
||||
|
||||
return mutate()
|
||||
.then(() => {
|
||||
return wrapper.vm.$nextTick();
|
||||
})
|
||||
.then(() => {
|
||||
expect(findReplyForm().exists()).toBe(false);
|
||||
expect(findNoteContent().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,6 +1,15 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import DesignReplyForm from '~/design_management/components/design_notes/design_reply_form.vue';
|
||||
|
||||
const showModal = jest.fn();
|
||||
|
||||
const GlModal = {
|
||||
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
|
||||
methods: {
|
||||
show: showModal,
|
||||
},
|
||||
};
|
||||
|
||||
describe('Design reply form component', () => {
|
||||
let wrapper;
|
||||
|
||||
|
@ -16,6 +25,7 @@ describe('Design reply form component', () => {
|
|||
isSaving: false,
|
||||
...props,
|
||||
},
|
||||
stubs: { GlModal },
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -29,6 +39,18 @@ describe('Design reply form component', () => {
|
|||
expect(findTextarea().element).toEqual(document.activeElement);
|
||||
});
|
||||
|
||||
it('renders button text as "Comment" when creating a comment', () => {
|
||||
createComponent();
|
||||
|
||||
expect(findSubmitButton().html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders button text as "Save comment" when creating a comment', () => {
|
||||
createComponent({ isNewComment: false });
|
||||
|
||||
expect(findSubmitButton().html()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('when form has no text', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({
|
||||
|
@ -120,16 +142,34 @@ describe('Design reply form component', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('opens confirmation modal on pressing Escape button', () => {
|
||||
it('emits cancelForm event on Escape key if text was not changed', () => {
|
||||
findTextarea().trigger('keyup.esc');
|
||||
|
||||
expect(findModal().exists()).toBe(true);
|
||||
expect(wrapper.emitted('cancelForm')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens confirmation modal on Cancel button click', () => {
|
||||
findCancelButton().vm.$emit('click');
|
||||
it('opens confirmation modal on Escape key when text has changed', () => {
|
||||
wrapper.setProps({ value: 'test2' });
|
||||
|
||||
expect(findModal().exists()).toBe(true);
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
findTextarea().trigger('keyup.esc');
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits cancelForm event on Cancel button click if text was not changed', () => {
|
||||
findCancelButton().trigger('click');
|
||||
|
||||
expect(wrapper.emitted('cancelForm')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens confirmation modal on Cancel button click when text has changed', () => {
|
||||
wrapper.setProps({ value: 'test2' });
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
findCancelButton().trigger('click');
|
||||
expect(showModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('emits cancelForm event on modal Ok button click', () => {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { editor as monacoEditor, Uri } from 'monaco-editor';
|
||||
import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor';
|
||||
import Editor from '~/editor/editor_lite';
|
||||
import { DEFAULT_THEME, themes } from '~/ide/lib/themes';
|
||||
|
||||
|
@ -41,13 +41,13 @@ describe('Base editor', () => {
|
|||
let dispose;
|
||||
|
||||
beforeEach(() => {
|
||||
setModel = jasmine.createSpy();
|
||||
dispose = jasmine.createSpy();
|
||||
modelSpy = spyOn(monacoEditor, 'createModel').and.returnValue(fakeModel);
|
||||
instanceSpy = spyOn(monacoEditor, 'create').and.returnValue({
|
||||
setModel = jest.fn();
|
||||
dispose = jest.fn();
|
||||
modelSpy = jest.spyOn(monacoEditor, 'createModel').mockImplementation(() => fakeModel);
|
||||
instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({
|
||||
setModel,
|
||||
dispose,
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
it('does nothing if no dom element is supplied', () => {
|
||||
|
@ -73,7 +73,7 @@ describe('Base editor', () => {
|
|||
editor.createInstance({ el: editorEl });
|
||||
|
||||
expect(editor.editorEl).not.toBe(null);
|
||||
expect(instanceSpy).toHaveBeenCalledWith(editorEl, jasmine.anything());
|
||||
expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -91,6 +91,11 @@ describe('Base editor', () => {
|
|||
});
|
||||
|
||||
it('is capable of changing the language of the model', () => {
|
||||
// ignore warnings and errors Monaco posts during setup
|
||||
// (due to being called from Jest/Node.js environment)
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const blobRenamedPath = 'test.js';
|
||||
|
||||
expect(editor.model.getLanguageIdentifier().language).toEqual('markdown');
|
||||
|
@ -101,7 +106,7 @@ describe('Base editor', () => {
|
|||
|
||||
it('falls back to plaintext if there is no language associated with an extension', () => {
|
||||
const blobRenamedPath = 'test.myext';
|
||||
const spy = spyOn(console, 'error');
|
||||
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
editor.updateModelLanguage(blobRenamedPath);
|
||||
|
||||
|
@ -110,14 +115,26 @@ describe('Base editor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('languages', () => {
|
||||
it('registers custom languages defined with Monaco', () => {
|
||||
expect(monacoLanguages.getLanguages()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'vue',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syntax highlighting theme', () => {
|
||||
let themeDefineSpy;
|
||||
let themeSetSpy;
|
||||
let defaultScheme;
|
||||
|
||||
beforeEach(() => {
|
||||
themeDefineSpy = spyOn(monacoEditor, 'defineTheme');
|
||||
themeSetSpy = spyOn(monacoEditor, 'setTheme');
|
||||
themeDefineSpy = jest.spyOn(monacoEditor, 'defineTheme').mockImplementation(() => {});
|
||||
themeSetSpy = jest.spyOn(monacoEditor, 'setTheme').mockImplementation(() => {});
|
||||
defaultScheme = window.gon.user_color_scheme;
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { editor as monacoEditor } from 'monaco-editor';
|
||||
import { editor as monacoEditor, languages as monacoLanguages } from 'monaco-editor';
|
||||
import Editor from '~/ide/lib/editor';
|
||||
import { defaultEditorOptions } from '~/ide/lib/editor_options';
|
||||
import { file } from '../helpers';
|
||||
|
@ -181,6 +181,18 @@ describe('Multi-file editor library', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('languages', () => {
|
||||
it('registers custom languages defined with Monaco', () => {
|
||||
expect(monacoLanguages.getLanguages()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: 'vue',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispose', () => {
|
||||
it('calls disposble dispose method', () => {
|
||||
jest.spyOn(instance.disposable, 'dispose');
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
import { editor } from 'monaco-editor';
|
||||
import { registerLanguages } from '~/ide/utils';
|
||||
import vue from '~/ide/lib/languages/vue';
|
||||
|
||||
// This file only tests syntax specific to vue. This does not test existing syntaxes
|
||||
// of html, javascript, css and handlebars, which vue files extend.
|
||||
describe('tokenization for .vue files', () => {
|
||||
beforeEach(() => {
|
||||
registerLanguages(vue);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[
|
||||
'<div v-if="something">content</div>',
|
||||
[
|
||||
[
|
||||
{ language: 'vue', offset: 0, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 1, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 4, type: '' },
|
||||
{ language: 'vue', offset: 5, type: 'variable' },
|
||||
{ language: 'vue', offset: 21, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 22, type: '' },
|
||||
{ language: 'vue', offset: 29, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 31, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 34, type: 'delimiter.html' },
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'<input :placeholder="placeholder">',
|
||||
[
|
||||
[
|
||||
{ language: 'vue', offset: 0, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 1, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 6, type: '' },
|
||||
{ language: 'vue', offset: 7, type: 'variable' },
|
||||
{ language: 'vue', offset: 33, type: 'delimiter.html' },
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'<gl-modal @ok="submitForm()"></gl-modal>',
|
||||
[
|
||||
[
|
||||
{ language: 'vue', offset: 0, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 1, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 3, type: 'attribute.name' },
|
||||
{ language: 'vue', offset: 9, type: '' },
|
||||
{ language: 'vue', offset: 10, type: 'variable' },
|
||||
{ language: 'vue', offset: 28, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 31, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 33, type: 'attribute.name' },
|
||||
{ language: 'vue', offset: 39, type: 'delimiter.html' },
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'<a v-on:click.stop="doSomething">...</a>',
|
||||
[
|
||||
[
|
||||
{ language: 'vue', offset: 0, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 1, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 2, type: '' },
|
||||
{ language: 'vue', offset: 3, type: 'variable' },
|
||||
{ language: 'vue', offset: 32, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 33, type: '' },
|
||||
{ language: 'vue', offset: 36, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 38, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 39, type: 'delimiter.html' },
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
'<a @[event]="doSomething">...</a>',
|
||||
[
|
||||
[
|
||||
{ language: 'vue', offset: 0, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 1, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 2, type: '' },
|
||||
{ language: 'vue', offset: 3, type: 'variable' },
|
||||
{ language: 'vue', offset: 25, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 26, type: '' },
|
||||
{ language: 'vue', offset: 29, type: 'delimiter.html' },
|
||||
{ language: 'vue', offset: 31, type: 'tag.html' },
|
||||
{ language: 'vue', offset: 32, type: 'delimiter.html' },
|
||||
],
|
||||
],
|
||||
],
|
||||
])('%s', (string, tokens) => {
|
||||
expect(editor.tokenize(string, 'vue')).toEqual(tokens);
|
||||
});
|
||||
});
|
|
@ -1,6 +1,7 @@
|
|||
import { commitItemIconMap } from '~/ide/constants';
|
||||
import { getCommitIconMap, isTextFile } from '~/ide/utils';
|
||||
import { getCommitIconMap, isTextFile, registerLanguages } from '~/ide/utils';
|
||||
import { decorateData } from '~/ide/stores/utils';
|
||||
import { languages } from 'monaco-editor';
|
||||
|
||||
describe('WebIDE utils', () => {
|
||||
describe('isTextFile', () => {
|
||||
|
@ -102,4 +103,78 @@ describe('WebIDE utils', () => {
|
|||
expect(getCommitIconMap(entry)).toEqual(commitItemIconMap.modified);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerLanguages', () => {
|
||||
let langs;
|
||||
|
||||
beforeEach(() => {
|
||||
langs = [
|
||||
{
|
||||
id: 'html',
|
||||
extensions: ['.html'],
|
||||
conf: { comments: { blockComment: ['<!--', '-->'] } },
|
||||
language: { tokenizer: {} },
|
||||
},
|
||||
{
|
||||
id: 'css',
|
||||
extensions: ['.css'],
|
||||
conf: { comments: { blockComment: ['/*', '*/'] } },
|
||||
language: { tokenizer: {} },
|
||||
},
|
||||
{
|
||||
id: 'js',
|
||||
extensions: ['.js'],
|
||||
conf: { comments: { blockComment: ['/*', '*/'] } },
|
||||
language: { tokenizer: {} },
|
||||
},
|
||||
];
|
||||
|
||||
jest.spyOn(languages, 'register').mockImplementation(() => {});
|
||||
jest.spyOn(languages, 'setMonarchTokensProvider').mockImplementation(() => {});
|
||||
jest.spyOn(languages, 'setLanguageConfiguration').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('registers all the passed languages with Monaco', () => {
|
||||
registerLanguages(...langs);
|
||||
|
||||
expect(languages.register.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
conf: { comments: { blockComment: ['/*', '*/'] } },
|
||||
extensions: ['.css'],
|
||||
id: 'css',
|
||||
language: { tokenizer: {} },
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
conf: { comments: { blockComment: ['/*', '*/'] } },
|
||||
extensions: ['.js'],
|
||||
id: 'js',
|
||||
language: { tokenizer: {} },
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
conf: { comments: { blockComment: ['<!--', '-->'] } },
|
||||
extensions: ['.html'],
|
||||
id: 'html',
|
||||
language: { tokenizer: {} },
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(languages.setMonarchTokensProvider.mock.calls).toEqual([
|
||||
['css', { tokenizer: {} }],
|
||||
['js', { tokenizer: {} }],
|
||||
['html', { tokenizer: {} }],
|
||||
]);
|
||||
|
||||
expect(languages.setLanguageConfiguration.mock.calls).toEqual([
|
||||
['css', { comments: { blockComment: ['/*', '*/'] } }],
|
||||
['js', { comments: { blockComment: ['/*', '*/'] } }],
|
||||
['html', { comments: { blockComment: ['<!--', '-->'] } }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import * as monitoringUtils from '~/monitoring/utils';
|
||||
import { queryToObject, mergeUrlParams, removeParams } from '~/lib/utils/url_utility';
|
||||
import * as urlUtils from '~/lib/utils/url_utility';
|
||||
import { TEST_HOST } from 'jest/helpers/test_constants';
|
||||
import {
|
||||
mockProjectDir,
|
||||
|
@ -7,9 +7,7 @@ import {
|
|||
anomalyMockGraphData,
|
||||
barMockData,
|
||||
} from './mock_data';
|
||||
import { graphData } from './fixture_data';
|
||||
|
||||
jest.mock('~/lib/utils/url_utility');
|
||||
import { metricsDashboardViewModel, graphData } from './fixture_data';
|
||||
|
||||
const mockPath = `${TEST_HOST}${mockProjectDir}/-/environments/29/metrics`;
|
||||
|
||||
|
@ -27,11 +25,6 @@ const rollingRange = {
|
|||
};
|
||||
|
||||
describe('monitoring/utils', () => {
|
||||
afterEach(() => {
|
||||
mergeUrlParams.mockReset();
|
||||
queryToObject.mockReset();
|
||||
});
|
||||
|
||||
describe('trackGenerateLinkToChartEventOptions', () => {
|
||||
it('should return Cluster Monitoring options if located on Cluster Health Dashboard', () => {
|
||||
document.body.dataset.page = 'groups:clusters:show';
|
||||
|
@ -139,18 +132,25 @@ describe('monitoring/utils', () => {
|
|||
});
|
||||
|
||||
describe('timeRangeFromUrl', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(urlUtils, 'queryToObject');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
urlUtils.queryToObject.mockRestore();
|
||||
});
|
||||
|
||||
const { timeRangeFromUrl } = monitoringUtils;
|
||||
|
||||
it('returns a fixed range when query contains `start` and `end` paramters are given', () => {
|
||||
queryToObject.mockReturnValueOnce(range);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Snippet Description component matches the snapshot 1`] = `
|
||||
<markdown-field-view-stub
|
||||
class="snippet-description"
|
||||
data-qa-selector="snippet_description_field"
|
||||
>
|
||||
<div
|
||||
class="md js-snippet-description"
|
||||
>
|
||||
<h2>
|
||||
The property of Thor
|
||||
</h2>
|
||||
</div>
|
||||
</markdown-field-view-stub>
|
||||
`;
|
|
@ -0,0 +1,27 @@
|
|||
import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
describe('Snippet Description component', () => {
|
||||
let wrapper;
|
||||
const description = '<h2>The property of Thor</h2>';
|
||||
|
||||
function createComponent() {
|
||||
wrapper = shallowMount(SnippetDescription, {
|
||||
propsData: {
|
||||
description,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('matches the snapshot', () => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -1,4 +1,5 @@
|
|||
import SnippetTitle from '~/snippets/components/snippet_title.vue';
|
||||
import SnippetDescription from '~/snippets/components/snippet_description_view.vue';
|
||||
import { GlSprintf } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
|
@ -36,8 +37,9 @@ describe('Snippet header component', () => {
|
|||
|
||||
it('renders snippets title and description', () => {
|
||||
createComponent();
|
||||
|
||||
expect(wrapper.text().trim()).toContain(title);
|
||||
expect(wrapper.find('.js-snippet-description').element.innerHTML).toBe(descriptionHtml);
|
||||
expect(wrapper.find(SnippetDescription).props('description')).toBe(descriptionHtml);
|
||||
});
|
||||
|
||||
it('does not render recent changes time stamp if there were no updates', () => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue';
|
||||
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
|
||||
import { handleBlobRichViewer } from '~/blob/viewer';
|
||||
|
||||
jest.mock('~/blob/viewer');
|
||||
|
@ -33,4 +34,8 @@ describe('Blob Rich Viewer component', () => {
|
|||
it('queries for advanced viewer', () => {
|
||||
expect(handleBlobRichViewer).toHaveBeenCalledWith(expect.anything(), defaultType);
|
||||
});
|
||||
|
||||
it('is using Markdown View Field', () => {
|
||||
expect(wrapper.contains(MarkdownFieldView)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
import $ from 'jquery';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue';
|
||||
|
||||
describe('Markdown Field View component', () => {
|
||||
let renderGFMSpy;
|
||||
let wrapper;
|
||||
|
||||
function createComponent() {
|
||||
wrapper = shallowMount(MarkdownFieldView);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('processes rendering with GFM', () => {
|
||||
expect(renderGFMSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
|
@ -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,16 +20,6 @@ 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)
|
||||
|
||||
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)
|
||||
|
||||
|
@ -37,6 +28,15 @@ describe Gitlab::Metrics::Samplers::RubySampler do
|
|||
sampler.sample
|
||||
end
|
||||
|
||||
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_unique_memory_bytes]).to receive(:set).with({}, 9000)
|
||||
expect(sampler.metrics[:process_proportional_memory_bytes]).to receive(:set).with({}, 10_000)
|
||||
|
||||
sampler.sample
|
||||
end
|
||||
|
||||
it 'adds a metric containing the amount of open file descriptors' do
|
||||
expect(Gitlab::Metrics::System).to receive(:file_descriptor_count)
|
||||
.and_return(4)
|
||||
|
|
|
@ -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
|
||||
else
|
||||
|
||||
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
|
||||
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
|
||||
|
|
|
@ -0,0 +1,136 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe AlertManagement::ProcessPrometheusAlertService do
|
||||
let_it_be(:project) { create(:project) }
|
||||
|
||||
describe '#execute' do
|
||||
subject { described_class.new(project, nil, payload).execute }
|
||||
|
||||
context 'when alert payload is valid' do
|
||||
let(:parsed_alert) { Gitlab::Alerting::Alert.new(project: project, payload: payload) }
|
||||
let(:payload) do
|
||||
{
|
||||
'status' => status,
|
||||
'labels' => {
|
||||
'alertname' => 'GitalyFileServerDown',
|
||||
'channel' => 'gitaly',
|
||||
'pager' => 'pagerduty',
|
||||
'severity' => 's1'
|
||||
},
|
||||
'annotations' => {
|
||||
'description' => 'Alert description',
|
||||
'runbook' => 'troubleshooting/gitaly-down.md',
|
||||
'title' => 'Alert title'
|
||||
},
|
||||
'startsAt' => '2020-04-27T10:10:22.265949279Z',
|
||||
'endsAt' => '2020-04-27T10:20:22.265949279Z',
|
||||
'generatorURL' => 'http://8d467bd4607a:9090/graph?g0.expr=vector%281%29&g0.tab=1',
|
||||
'fingerprint' => 'b6ac4d42057c43c1'
|
||||
}
|
||||
end
|
||||
|
||||
context 'when Prometheus alert status is firing' do
|
||||
let(:status) { 'firing' }
|
||||
|
||||
context 'when alert with the same fingerprint already exists' do
|
||||
let!(:alert) { create(:alert_management_alert, :resolved, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
|
||||
|
||||
context 'when status can be changed' do
|
||||
it 'changes status to triggered' do
|
||||
expect { subject }.to change { alert.reload.triggered? }.to(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status change did not succeed' do
|
||||
before do
|
||||
allow(AlertManagement::Alert).to receive(:for_fingerprint).and_return([alert])
|
||||
allow(alert).to receive(:trigger).and_return(false)
|
||||
end
|
||||
|
||||
it 'writes a warning to the log' do
|
||||
expect(Gitlab::AppLogger).to receive(:warn).with(
|
||||
message: 'Unable to update AlertManagement::Alert status to triggered',
|
||||
project_id: project.id,
|
||||
alert_id: alert.id
|
||||
)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
it { is_expected.to be_success }
|
||||
end
|
||||
|
||||
context 'when alert does not exist' do
|
||||
context 'when alert can be created' do
|
||||
it 'creates a new alert' do
|
||||
expect { subject }.to change { AlertManagement::Alert.where(project: project).count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when alert cannot be created' do
|
||||
let(:errors) { double(messages: { hosts: ['hosts array is over 255 chars'] })}
|
||||
let(:am_alert) { instance_double(AlertManagement::Alert, save: false, errors: errors) }
|
||||
|
||||
before do
|
||||
allow(AlertManagement::Alert).to receive(:new).and_return(am_alert)
|
||||
end
|
||||
|
||||
it 'writes a warning to the log' do
|
||||
expect(Gitlab::AppLogger).to receive(:warn).with(
|
||||
message: 'Unable to create AlertManagement::Alert',
|
||||
project_id: project.id,
|
||||
alert_errors: { hosts: ['hosts array is over 255 chars'] }
|
||||
)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
it { is_expected.to be_success }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when Prometheus alert status is resolved' do
|
||||
let(:status) { 'resolved' }
|
||||
let!(:alert) { create(:alert_management_alert, project: project, fingerprint: parsed_alert.gitlab_fingerprint) }
|
||||
|
||||
context 'when status can be changed' do
|
||||
it 'resolves an existing alert' do
|
||||
expect { subject }.to change { alert.reload.resolved? }.to(true)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status change did not succeed' do
|
||||
before do
|
||||
allow(AlertManagement::Alert).to receive(:for_fingerprint).and_return([alert])
|
||||
allow(alert).to receive(:resolve).and_return(false)
|
||||
end
|
||||
|
||||
it 'writes a warning to the log' do
|
||||
expect(Gitlab::AppLogger).to receive(:warn).with(
|
||||
message: 'Unable to update AlertManagement::Alert status to resolved',
|
||||
project_id: project.id,
|
||||
alert_id: alert.id
|
||||
)
|
||||
|
||||
subject
|
||||
end
|
||||
end
|
||||
|
||||
it { is_expected.to be_success }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when alert payload is invalid' do
|
||||
let(:payload) { {} }
|
||||
|
||||
it 'responds with bad_request' do
|
||||
expect(subject).to be_error
|
||||
expect(subject.http_status).to eq(:bad_request)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue