Commit bfc02777 authored by Clement Ho's avatar Clement Ho

Merge branch 'master' into ce-to-ee-2018-11-07

parents 18fb1f02 d8ef45c2
......@@ -76,7 +76,6 @@ export default {
<noteable-discussion
v-show="isExpanded(discussion)"
:discussion="discussion"
:render-header="false"
:render-diff-file="false"
:always-expanded="true"
:discussions-by-diff-order="true"
......
......@@ -76,8 +76,9 @@ export default {
:class="className"
class="notes_holder"
>
<td class="notes_line old"></td>
<td class="notes_content parallel old">
<td
class="notes_content parallel old"
colspan="2">
<div
v-if="shouldRenderDiscussionsOnLeft"
class="content"
......@@ -95,8 +96,9 @@ export default {
line-position="left"
/>
</td>
<td class="notes_line new"></td>
<td class="notes_content parallel new">
<td
class="notes_content parallel new"
colspan="2">
<div
v-if="shouldRenderDiscussionsOnRight"
class="content"
......
......@@ -321,10 +321,10 @@ Please check your network connection and try again.`;
v-else-if="!canCreateNote"
:issuable-type="issuableTypeTitle"
/>
<ul
<div
v-else-if="canCreateNote"
class="notes notes-form timeline">
<li class="timeline-entry">
<div class="timeline-entry note-form">
<div class="timeline-entry-inner">
<div class="flash-container error-alert timeline-content"></div>
<div class="timeline-icon d-none d-sm-none d-md-block">
......@@ -462,7 +462,7 @@ append-right-10 comment-type-dropdown js-comment-type-dropdown droplab-dropdown"
</form>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import resolveSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedSvg from 'icons/_icon_status_success_solid.svg';
import mrIssueSvg from 'icons/_icon_mr_issue.svg';
import nextDiscussionSvg from 'icons/_next_discussion.svg';
import Icon from '~/vue_shared/components/icon.vue';
import { pluralize } from '../../lib/utils/text_utility';
import discussionNavigation from '../mixins/discussion_navigation';
import tooltip from '../../vue_shared/directives/tooltip';
......@@ -12,6 +9,9 @@ export default {
directives: {
tooltip,
},
components: {
Icon,
},
mixins: [discussionNavigation],
computed: {
...mapGetters([
......@@ -37,12 +37,6 @@ export default {
return this.getNoteableData.create_issue_to_resolve_discussions_path;
},
},
created() {
this.resolveSvg = resolveSvg;
this.resolvedSvg = resolvedSvg;
this.mrIssueSvg = mrIssueSvg;
this.nextDiscussionSvg = nextDiscussionSvg;
},
methods: {
...mapActions(['expandDiscussion']),
jumpToFirstUnresolvedDiscussion() {
......@@ -66,15 +60,9 @@ export default {
<span
:class="{ 'is-active': allResolved }"
class="line-resolve-btn is-disabled"
type="button">
<span
v-if="allResolved"
v-html="resolvedSvg"
></span>
<span
v-else
v-html="resolveSvg"
></span>
type="button"
>
<icon name="check-circle" />
</span>
<span class="line-resolve-text">
{{ resolvedDiscussionCount }}/{{ discussionCount }} {{ countText }} resolved
......@@ -90,7 +78,7 @@ export default {
:title="s__('Resolve all discussions in new issue')"
data-container="body"
class="new-issue-for-discussion btn btn-default discussion-create-issue-btn">
<span v-html="mrIssueSvg"></span>
<icon name="issue-new" />
</a>
</div>
<div
......@@ -103,7 +91,7 @@ export default {
data-container="body"
class="btn btn-default discussion-next-btn"
@click="jumpToFirstUnresolvedDiscussion">
<span v-html="nextDiscussionSvg"></span>
<icon name="comment-next" />
</button>
</div>
</div>
......
<script>
import { mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import editSvg from 'icons/_icon_pencil.svg';
import resolveDiscussionSvg from 'icons/_icon_resolve_discussion.svg';
import resolvedDiscussionSvg from 'icons/_icon_status_success_solid.svg';
import ellipsisSvg from 'icons/_ellipsis_v.svg';
import Icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import { GlLoadingIcon } from '@gitlab-org/gitlab-ui';
......@@ -110,15 +103,6 @@ export default {
return title;
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
this.editSvg = editSvg;
this.ellipsisSvg = ellipsisSvg;
this.resolveDiscussionSvg = resolveDiscussionSvg;
this.resolvedDiscussionSvg = resolvedDiscussionSvg;
},
methods: {
onEdit() {
this.$emit('handleEdit');
......@@ -152,12 +136,7 @@ export default {
class="line-resolve-btn note-action-button"
@click="onResolve">
<template v-if="!isResolving">
<div
v-if="isResolved"
v-html="resolvedDiscussionSvg"></div>
<div
v-else
v-html="resolveDiscussionSvg"></div>
<icon name="check-circle" />
</template>
<gl-loading-icon
v-else
......@@ -179,18 +158,18 @@ export default {
title="Add reaction"
>
<gl-loading-icon inline/>
<span
class="link-highlight award-control-icon-neutral"
v-html="emojiSmiling">
</span>
<span
class="link-highlight award-control-icon-positive"
v-html="emojiSmiley">
</span>
<span
class="link-highlight award-control-icon-super-positive"
v-html="emojiSmile">
</span>
<icon
css-classes="link-highlight award-control-icon-neutral"
name="emoji_slightly_smiling_face"
/>
<icon
css-classes="link-highlight award-control-icon-positive"
name="emoji_smiley"
/>
<icon
css-classes="link-highlight award-control-icon-super-positive"
name="emoji_smiley"
/>
</a>
</div>
<div
......@@ -204,10 +183,10 @@ export default {
data-container="body"
data-placement="bottom"
@click="onEdit">
<span
class="link-highlight"
v-html="editSvg">
</span>
<icon
name="pencil"
css-classes="link-highlight"
/>
</button>
</div>
<div
......@@ -240,10 +219,10 @@ export default {
data-toggle="dropdown"
data-container="body"
data-placement="bottom">
<span
class="icon"
v-html="ellipsisSvg">
</span>
<icon
css-classes="icon"
name="ellipsis_v"
/>
</button>
<ul class="dropdown-menu more-actions-dropdown dropdown-open-left">
<li v-if="canReportAsAbuse">
......
<script>
import { mapActions, mapGetters } from 'vuex';
import emojiSmiling from 'icons/_emoji_slightly_smiling_face.svg';
import emojiSmile from 'icons/_emoji_smile.svg';
import emojiSmiley from 'icons/_emoji_smiley.svg';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import { glEmojiTag } from '../../emoji';
import tooltip from '../../vue_shared/directives/tooltip';
export default {
components: {
Icon,
},
directives: {
tooltip,
},
......@@ -72,11 +73,6 @@ export default {
return this.noteAuthorId === this.getUserData.id;
},
},
created() {
this.emojiSmiling = emojiSmiling;
this.emojiSmile = emojiSmile;
this.emojiSmiley = emojiSmiley;
},
methods: {
...mapActions(['toggleAwardRequest']),
getAwardHTML(name) {
......@@ -196,17 +192,14 @@ export default {
data-boundary="viewport"
data-placement="bottom"
type="button">
<span
class="award-control-icon award-control-icon-neutral"
v-html="emojiSmiling">
<span class="award-control-icon award-control-icon-neutral">
<icon name="emoji_slightly_smiling_face" />
</span>
<span
class="award-control-icon award-control-icon-positive"
v-html="emojiSmiley">
<span class="award-control-icon award-control-icon-positive">
<icon name="emoji_smiley" />
</span>
<span
class="award-control-icon award-control-icon-super-positive"
v-html="emojiSmile">
<span class="award-control-icon award-control-icon-super-positive">
<icon name="emoji_smiley" />
</span>
<i
aria-hidden="true"
......
......@@ -45,6 +45,9 @@ export default {
noteTimestampLink() {
return `#note_${this.noteId}`;
},
hasAuthor() {
return this.author && Object.keys(this.author).length;
},
},
methods: {
...mapActions(['setTargetNoteHash']),
......@@ -76,7 +79,7 @@ export default {
</button>
</div>
<a
v-if="Object.keys(author).length"
v-if="hasAuthor"
:href="author.path"
>
<span class="note-header-author-name">{{ author.name }}</span>
......@@ -92,9 +95,6 @@ export default {
</span>
<span class="note-headline-light">
<span class="note-headline-meta">
<template v-if="actionText">
{{ actionText }}
</template>
<span class="system-note-message">
<slot></slot>
</span>
......@@ -102,7 +102,9 @@ export default {
v-if="createdAt"
>
<span class="system-note-separator">
&middot;
<template v-if="actionText">
{{ actionText }}
</template>
</span>
<a
:href="noteTimestampLink"
......
......@@ -176,7 +176,7 @@ export default {
:class="classNameBindings"
:data-award-url="note.toggle_award_path"
:data-note-id="note.id"
class="note timeline-entry"
class="note timeline-entry note-wrapper"
>
<div class="timeline-entry-inner">
<div class="timeline-icon">
......@@ -199,6 +199,7 @@ export default {
:author="author"
:created-at="note.created_at"
:note-id="note.id"
action-text="commented"
/>
<note-actions
:author-id="author.id"
......
<script>
import _ from 'underscore';
import Icon from '~/vue_shared/components/icon.vue';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
Icon,
UserAvatarLink,
TimeAgoTooltip,
},
props: {
collapsed: {
type: Boolean,
required: true,
},
replies: {
type: Array,
required: true,
},
},
computed: {
lastReply() {
return this.replies[this.replies.length - 1];
},
uniqueAuthors() {
const authors = this.replies.map(reply => reply.author || {});
return _.uniq(authors, author => author.username);
},
className() {
return this.collapsed ? 'collapsed' : 'expanded';
},
},
methods: {
toggle() {
this.$emit('toggle');
},
},
};
</script>
<template>
<li
:class="className"
class="replies-toggle"
>
<template v-if="collapsed">
<icon
name="chevron-right"
@click.native="toggle"
/>
<div>
<user-avatar-link
v-for="author in uniqueAuthors"
:key="author.username"
:link-href="author.path"
:img-alt="author.name"
:img-src="author.avatar_url"
:img-size="26"
:tooltip-text="author.name"
tooltip-placement="bottom"
/>
</div>
<button
class="btn btn-link js-replies-text"
type="button"
@click="toggle"
>
{{ replies.length }} {{ n__('reply', 'replies', replies.length) }}
</button>
{{ __('Last reply by') }}
<a
:href="lastReply.author.path"
class="btn btn-link author-link"
>
{{ lastReply.author.name }}
</a>
<time-ago-tooltip
:time="lastReply.created_at"
tooltip-placement="bottom"
/>
</template>
<span
v-else
class="collapse-replies-btn js-collapse-replies"
@click="toggle"
>
<icon name="chevron-down" />
{{ s__('Notes|Collapse replies') }}
</span>
</li>
</template>
......@@ -10,7 +10,7 @@ export default {
</script>
<template>
<li class="timeline-entry note">
<li class="timeline-entry note note-wrapper">
<div class="timeline-entry-inner">
<div class="timeline-icon">
</div>
......
......@@ -76,7 +76,7 @@ export default {
<li
:id="noteAnchorId"
:class="{ target: isTargetNote }"
class="note system-note timeline-entry">
class="note system-note timeline-entry note-wrapper">
<div class="timeline-entry-inner">
<div
class="timeline-icon"
......
......@@ -148,10 +148,7 @@
.award-control-icon svg {
background: $award-emoji-positive-add-bg;
path {
fill: $award-emoji-positive-add-lines;
}
fill: $award-emoji-positive-add-lines;
}
.award-control-icon-neutral {
......
......@@ -218,6 +218,25 @@
}
}
&.btn-text-field {
width: 100%;
text-align: left;
padding: 6px 16px;
border-color: $border-color;
color: $gray-darkest;
background-color: $gray-light;
&:hover,
&:active,
&:focus {
cursor: text;
box-shadow: none;
border-color: lighten($blue-300, 20%);
color: $gray-darkest;
background-color: $gray-light;
}
}
&.dot-highlight::after {
content: '';
background-color: $blue-500;
......@@ -335,25 +354,6 @@
}
}
.btn-text-field {
width: 100%;
text-align: left;
padding: 6px 16px;
border-color: $border-color;
color: $gray-darkest;
background-color: $gray-light;
&:hover,
&:active,
&:focus {
cursor: text;
box-shadow: none;
border-color: lighten($blue-300, 20%);
color: $gray-darkest;
background-color: $gray-light;
}
}
.btn-build {
margin-left: 10px;
......
......@@ -36,7 +36,6 @@
text-align: left;
padding: 10px $gl-padding;
word-wrap: break-word;
border-radius: $border-radius-default $border-radius-default 0 0;
&.file-title-clear {
padding-left: 0;
......
.timeline {
@include basic-list;
margin: 0;
padding: 0;
list-style: none;
&::before {
@include notes-media('max', map-get($grid-breakpoints, sm)) {
......@@ -26,10 +26,8 @@
}
.timeline-entry {
border-color: $white-normal;
color: $gl-text-color;
border-bottom: 1px solid $border-white-light;
background: $white-light;
background-color: $white-light;
.timeline-entry-inner {
position: relative;
......
......@@ -59,6 +59,7 @@
margin: 0;
padding: 0;
table-layout: fixed;
border-radius: 0 0 $border-radius-default $border-radius-default;
.diff-line-num {
width: 50px;
......@@ -859,7 +860,7 @@
}
.diff-file .note-container > .new-note,
.note-container .discussion-notes {
.note-container .discussion-notes.diff-discussions {
margin-left: 100px;
border-left: 1px solid $white-normal;
}
......
......@@ -239,6 +239,7 @@
.discussion-reply-holder {
background-color: $white-light;
padding: 10px 16px;
border-radius: 0 0 $border-radius-default $border-radius-default;
&.is-replying {
padding-bottom: $gl-padding;
......@@ -247,10 +248,15 @@
}
.discussion-with-resolve-btn {
@include media-breakpoint-up(sm) {
display: flex;
}
.discussion-actions {
display: table;
.btn-default path {
svg {
fill: $gray-darkest;
}
......@@ -270,6 +276,12 @@
.btn {
width: 100%;
}
.btn-text-field {
@include media-breakpoint-down(xs) {
margin-bottom: $gl-padding-8;
}
}
}
.discussion-notes-count {
......
/**
* Notes
*/
$system-note-icon-size: 32px;
$system-note-svg-size: 16px;
$note-form-margin-left: 70px;
@-webkit-keyframes targe3-note {
from {
background: $note-targe3-outside;
@mixin vertical-line($left) {
&::before {
content: '';
border-left: 2px solid $theme-gray-100;
position: absolute;
top: 0;
bottom: 0;
left: $left;
}
}
50% {
background: $note-targe3-inside;
}
.note-wrapper {
padding: $gl-padding;
}
.issuable-discussion {
.notes.timeline > .timeline-entry {
border: 1px solid $border-color;
border-radius: $border-radius-default;
margin: $gl-padding 0;
&.system-note,
&.note-form {
border: 0;
}
&.note-form {
margin-left: 0;
to {
background: $note-targe3-outside;
@include notes-media('min', map-get($grid-breakpoints, md)) {
margin-left: $note-form-margin-left;
}
.timeline-icon {
@include notes-media('min', map-get($grid-breakpoints, sm)) {
margin-left: -$note-icon-gutter-width;
}
}
.timeline-content {
margin-left: 0;
}
}
.notes_content {
border: 0;
border-top: 1px solid $border-color;
}
}
}
ul.notes {
.main-notes-list {
@include vertical-line(39px);
}
.notes {
display: block;
list-style: none;
margin: 0;
padding: 0;
position: relative;
> .note-discussion {
.card {
border: 0;
}
li.note {
border-bottom: 1px solid $border-color;
&:first-child {
border-radius: $border-radius-default $border-radius-default 0 0;
}
}
}
.replies-toggle {
background-color: $gray-light;
padding: $gl-padding-8 $gl-padding;
.collapse-replies-btn:hover {
color: $blue-600;
}
&.expanded {
border-bottom: 1px solid $border-color;
span {
cursor: pointer;
}
svg {
position: relative;
top: 3px;
}
}
&.collapsed {
color: $gl-text-color-secondary;
svg {
float: left;
position: relative;
top: $gl-padding-4;
margin-right: $gl-padding-8;
cursor: pointer;
}
img {
margin: -2px 4px 0 0;
}
.author-link {
color: $gl-text-color;
}
}
.user-avatar-link {
&:last-child img {
margin-right: $gl-padding-8;
}
}
.btn-link {
border: 0;
vertical-align: baseline;
}
}
.note-created-ago,
.note-updated-at {
......@@ -28,8 +137,6 @@ ul.notes {
}
.discussion-body {
padding-top: 8px;
.card {
margin-bottom: 0;
}
......@@ -46,21 +153,10 @@ ul.notes {
}
> li {
// .timeline-entry
padding: 0;
display: block;
position: relative;
border-bottom: 0;
@include notes-media('min', map-get($grid-breakpoints, sm)) {
padding-left: $note-icon-gutter-width;
}
.timeline-entry-inner {
padding: $gl-padding $gl-btn-padding;
border-bottom: 1px solid $white-normal;
}
&:target,
&.target {
border-bottom: 1px solid $white-normal;
......@@ -75,23 +171,10 @@ ul.notes {
}
}
.timeline-icon {
@include notes-media('min', map-get($grid-breakpoints, sm)) {
margin-left: -$note-icon-gutter-width;
}
}
.timeline-content {
margin-left: $note-icon-gutter-width;
@include notes-media('min', map-get($grid-breakpoints, sm)) {
margin-left: 0;
}
}
&.being-posted {
pointer-events: none;
opacity: 0.5;
padding: $gl-padding;
.dummy-avatar {
background-color: $gl-gray-200;
......@@ -104,12 +187,6 @@ ul.notes {
}
}
&.note-discussion {
.timeline-entry-inner {
padding: $gl-padding 10px;
}
}
.editing-spinner {
display: none;
}
......@@ -191,8 +268,9 @@ ul.notes {
}
.system-note {
font-size: 14px;
clear: both;
padding: 6px $gl-padding-24;
margin: $gl-padding-24 0;
background-color: transparent;
.note-header-info {
padding-bottom: 0;
......@@ -225,17 +303,21 @@ ul.notes {
.timeline-icon {
float: left;
@include notes-media('min', map-get($grid-breakpoints, sm)) {
margin-left: 0;
width: auto;
}
display: flex;
align-items: center;
background-color: $white-light;
width: $system-note-icon-size;
height: $system-note-icon-size;
border: 1px solid $border-color;
border-radius: $system-note-icon-size;
margin: -6px $gl-padding 0 0;
svg {
width: 16px;
height: 16px;
width: $system-note-svg-size;
height: $system-note-svg-size;
fill: $gray-darkest;
margin-top: 2px;
display: block;
margin: 0 auto;
}
}
......@@ -302,10 +384,17 @@ ul.notes {
.discussion-body .diff-file {
.file-title {
cursor: default;
line-height: 42px;
padding: 0 $gl-padding;
border-top: 1px solid $border-color;
&:hover {
background-color: $gray-light;
}
.btn-clipboard {
top: 10px;
}
}
.line_content {
......@@ -320,6 +409,23 @@ ul.notes {
}
}
.discussion-notes {
&:not(:first-child) {
border-top: 1px solid $white-normal;
margin-top: 20px;
}
&:not(:last-child) {
border-bottom: 1px solid $white-normal;
margin-bottom: 20px;
}
.system-note {
margin: 0;
padding: $gl-padding;
}
}
// Merge request notes in diffs
// Diff is inline
.notes_content .note-header .note-headline-light {
......@@ -335,7 +441,6 @@ ul.notes {
border-left: 0;
&.notes_content {
background-color: $gray-light;
border-width: 1px 0;
padding: 0;
vertical-align: top;
......@@ -349,18 +454,6 @@ ul.notes {
}
}
.discussion-notes {
&:not(:first-child) {
border-top: 1px solid $white-normal;
margin-top: 20px;
}
&:not(:last-child) {
border-bottom: 1px solid $white-normal;
margin-bottom: 20px;
}
}
.notes {
background-color: $white-light;
}
......@@ -374,6 +467,30 @@ ul.notes {
}
}
.diffs {
.discussion-notes {
margin-left: 0;
border-left: 0;
.notes {
position: relative;
@include vertical-line(52px);
}
}
.note-wrapper {
margin: $gl-padding;
border: 1px solid $border-color;
border-radius: $border-radius-default;
}
.discussion-reply-holder {
border-radius: 0 0 $border-radius-default $border-radius-default;
border-top: 1px solid $border-color;
position: relative;
}
}
.discussion-header,
.note-header-info {
a {
......@@ -399,7 +516,17 @@ ul.notes {
}
.discussion-header {
font-size: 14px;
min-height: 72px;
.note-header-info {
padding-bottom: 0;
}
}
.unresolved {
.note-header-info {
margin-top: $gl-padding-8;
}
}
.note-header {
......@@ -409,7 +536,7 @@ ul.notes {
.note-header-info {
min-width: 0;
padding-bottom: 8px;
padding-bottom: $gl-padding-8;
&.discussion {
padding-bottom: 0;
......@@ -471,9 +598,18 @@ ul.notes {
margin-left: 10px;
color: $gray-darkest;
@include media-breakpoint-down(xs) {
width: 100%;
margin: $gl-padding-8 0;
}
.btn-group > .discussion-next-btn {
margin-left: -1px;
}
svg {
height: 15px;
}
}
.note-actions {
......@@ -585,19 +721,6 @@ ul.notes {
z-index: 10;
}
.discussion-body,
.diff-file {
.notes .note {
border-bottom: 1px solid $white-normal;
.timeline-entry-inner {
padding-left: $gl-padding;
padding-right: $gl-padding;
border-bottom: 0;
}
}
}
.disabled-comment {
background-color: $gray-light;
border-radius: $border-radius-base;
......@@ -634,7 +757,7 @@ ul.notes {
}
.btn {
svg path {
svg {
fill: $gray-darkest;
}
......@@ -659,7 +782,7 @@ ul.notes {
.line-resolve-all {
vertical-align: middle;
display: inline-block;
padding: 5px 10px 6px;
padding: 6px 10px;
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-default;
......
......@@ -542,4 +542,13 @@ module ProjectsHelper
network
]
end
def sidebar_operations_paths
%w[
environments
clusters
user
gcp
]
end
end
......@@ -58,4 +58,17 @@ class ProjectImportState < ActiveRecord::Base
end
end
end
def mark_as_failed(error_message)
original_errors = errors.dup
sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message)
fail_op
update_column(:last_error, sanitized_message)
rescue ActiveRecord::ActiveRecordError => e
Rails.logger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}")
ensure
@errors = original_errors
end
end
......@@ -195,7 +195,7 @@
= _('Charts')
- if project_nav_tab? :operations
= nav_link(controller: [:environments, :clusters, :user, :gcp, :feature_flags]) do
= nav_link(controller: sidebar_operations_paths) do
= link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do
.nav-icon-container
= sprite_icon('cloud-gear')
......@@ -203,7 +203,7 @@
= _('Operations')
%ul.sidebar-sub-level-items
= nav_link(controller: [:environments, :clusters, :user, :gcp, :feature_flags], html_options: { class: "fly-out-top-item" } ) do
= nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do
= link_to metrics_project_environments_path(@project) do
%strong.fly-out-top-item-name
= _('Operations')
......@@ -215,6 +215,8 @@
%span
= _('Metrics')
= render_if_exists "layouts/nav/sidebar/tracing_link"
= nav_link(controller: :environments, action: [:index, :folder, :show, :new, :edit, :create, :update, :stop, :terminal]) do
= link_to project_environments_path(@project), title: _('Environments'), class: 'shortcuts-environments qa-operations-environments-link' do
%span
......@@ -345,6 +347,9 @@
= link_to project_settings_ci_cd_path(@project), title: _('CI / CD') do
%span
= _('CI / CD')
= render_if_exists 'projects/sidebar/settings_operations'
- if @project.pages_available?
= nav_link(controller: :pages) do
= link_to project_pages_path(@project), title: _('Pages') do
......
......@@ -7,8 +7,8 @@
= render 'shared/notes/edit_form', project: @project
- if can_create_note?
%ul.notes.notes-form.timeline
%li.timeline-entry
.notes.notes-form.timeline
.timeline-entry
.timeline-entry-inner
.flash-container.timeline-content
......
......@@ -7,67 +7,65 @@ class StuckImportJobsWorker
IMPORT_JOBS_EXPIRATION = 15.hours.to_i
def perform
projects_without_jid_count = mark_projects_without_jid_as_failed!
projects_with_jid_count = mark_projects_with_jid_as_failed!
import_state_without_jid_count = mark_import_states_without_jid_as_failed!
import_state_with_jid_count = mark_import_states_with_jid_as_failed!
values = {
projects_without_jid_count: projects_without_jid_count,
projects_with_jid_count: projects_with_jid_count
projects_without_jid_count: import_state_without_jid_count,
projects_with_jid_count: import_state_with_jid_count
}
Gitlab::Metrics.add_event_with_values(:stuck_import_jobs, values)
stuck_import_jobs_worker_runs_counter.increment
projects_without_jid_metric.set({}, projects_without_jid_count)
projects_with_jid_metric.set({}, projects_with_jid_count)
import_state_without_jid_metric.set({}, import_state_without_jid_count)
import_state_with_jid_metric.set({}, import_state_with_jid_count)
end
private
def mark_projects_without_jid_as_failed!
enqueued_projects_without_jid.each do |project|
project.mark_import_as_failed(error_message)
def mark_import_states_without_jid_as_failed!
enqueued_import_states_without_jid.each do |import_state|
import_state.mark_as_failed(error_message)
end.count
end
# rubocop: disable CodeReuse/ActiveRecord
def mark_projects_with_jid_as_failed!
# TODO: Rollback this change to use SQL through #pluck
jids_and_ids = enqueued_projects_with_jid.map { |project| [project.import_jid, project.id] }.to_h
def mark_import_states_with_jid_as_failed!
jids_and_ids = enqueued_import_states_with_jid.pluck(:jid, :id).to_h
# Find the jobs that aren't currently running or that exceeded the threshold.
completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
return unless completed_jids.any?
completed_project_ids = jids_and_ids.values_at(*completed_jids)
completed_import_state_ids = jids_and_ids.values_at(*completed_jids)
# We select the projects again, because they may have transitioned from
# We select the import states again, because they may have transitioned from
# scheduled/started to finished/failed while we were looking up their Sidekiq status.
completed_projects = enqueued_projects_with_jid.where(id: completed_project_ids)
completed_import_states = enqueued_import_states_with_jid.where(id: completed_import_state_ids)
Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_projects.map(&:import_jid).join(', ')}")
completed_import_state_jids = completed_import_states.map { |import_state| import_state.jid }.join(', ')
Rails.logger.info("Marked stuck import jobs as failed. JIDs: #{completed_import_state_jids}")
completed_projects.each do |project|
project.mark_import_as_failed(error_message)
completed_import_states.each do |import_state|
import_state.mark_as_failed(error_message)
end.count
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def enqueued_projects
Project.joins_import_state.where("(import_state.status = 'scheduled' OR import_state.status = 'started') OR (projects.import_status = 'scheduled' OR projects.import_status = 'started')")
def enqueued_import_states
ProjectImportState.with_status([:scheduled, :started])
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def enqueued_projects_with_jid
enqueued_projects.where.not("import_state.jid IS NULL AND projects.import_jid IS NULL")
def enqueued_import_states_with_jid
enqueued_import_states.where.not(jid: nil)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def enqueued_projects_without_jid
enqueued_projects.where("import_state.jid IS NULL AND projects.import_jid IS NULL")
def enqueued_import_states_without_jid
enqueued_import_states.where(jid: nil)
end
# rubocop: enable CodeReuse/ActiveRecord
......@@ -80,11 +78,11 @@ class StuckImportJobsWorker
'Stuck import jobs worker runs count')
end
def projects_without_jid_metric
@projects_without_jid_metric ||= Gitlab::Metrics.gauge(:gitlab_projects_without_jid, 'Projects without Job ids')
def import_state_without_jid_metric
@import_state_without_jid_metric ||= Gitlab::Metrics.gauge(:gitlab_projects_without_jid, 'Projects without Job ids')
end
def projects_with_jid_metric
@projects_with_jid_metric ||= Gitlab::Metrics.gauge(:gitlab_projects_with_jid, 'Projects with Job ids')
def import_state_with_jid_metric
@import_state_with_jid_metric ||= Gitlab::Metrics.gauge(:gitlab_projects_with_jid, 'Projects with Job ids')
end
end
---
title: Improves performance of stuck import jobs detection
merge_request: 22879
author:
type: performance
......@@ -2293,6 +2293,15 @@ ActiveRecord::Schema.define(version: 20181107054254) do
add_index "project_statistics", ["namespace_id"], name: "index_project_statistics_on_namespace_id", using: :btree
add_index "project_statistics", ["project_id"], name: "index_project_statistics_on_project_id", unique: true, using: :btree
create_table "project_tracing_settings", id: :bigserial, force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.integer "project_id", null: false
t.string "external_url", null: false
end
add_index "project_tracing_settings", ["project_id"], name: "index_project_tracing_settings_on_project_id", unique: true, using: :btree
create_table "projects", force: :cascade do |t|
t.string "name"
t.string "path"
......@@ -3430,6 +3439,7 @@ ActiveRecord::Schema.define(version: 20181107054254) do
add_foreign_key "project_mirror_data", "projects", name: "fk_d1aad367d7", on_delete: :cascade
add_foreign_key "project_repository_states", "projects", on_delete: :cascade
add_foreign_key "project_statistics", "projects", on_delete: :cascade
add_foreign_key "project_tracing_settings", "projects", on_delete: :cascade
add_foreign_key "projects", "repositories", column: "pool_repository_id", name: "fk_6e5c14658a", on_delete: :nullify
add_foreign_key "prometheus_alert_events", "projects", on_delete: :cascade
add_foreign_key "prometheus_alert_events", "prometheus_alerts", on_delete: :cascade
......
......@@ -213,10 +213,10 @@ To bring a new secondary online, follow the [Geo setup instructions][setup-geo].
### Step 6. (Optional) Removing the secondary's tracking database
Every **secondary** has a special tracking database that is used to save the status of the synchronization of all the items from the **primary**.
Because the **secondary** is already promoted, that data in the tracking database is no longer required.
Because the **secondary** is already promoted, that data in the tracking database is no longer required.
The data can be removed with the following command:
```ssh
```sh
sudo rm -rf /var/opt/gitlab/geo-postgresql
```
......
# From Community Edition 11.5 to Enterprise Edition 11.5
This guide assumes you have a correctly configured and tested installation of
GitLab Community Edition 11.5. If you run into any trouble or if you have any
questions please contact us at [support@gitlab.com].
### 0. Backup
Make a backup just in case something goes wrong:
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
```
For installations using MySQL, this may require granting "LOCK TABLES"
privileges to the GitLab user on the database version.
### 1. Stop server
```bash
sudo service gitlab stop
```
### 2. Get the EE code
```bash
cd /home/git/gitlab
sudo -u git -H git remote add -f ee https://gitlab.com/gitlab-org/gitlab-ee.git
sudo -u git -H git checkout 11-5-stable-ee
```
### 3. Install libs, migrations, etc.
```bash
cd /home/git/gitlab
# MySQL installations (note: the line below states '--without postgres')
sudo -u git -H bundle install --without postgres development test --deployment
# PostgreSQL installations (note: the line below states '--without mysql')
sudo -u git -H bundle install --without mysql development test --deployment
# Run database migrations
sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
# Clean up assets and cache
sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production
```
### 4. Install `gitlab-elasticsearch-indexer` (optional) **[STARTER ONLY]**
If you're interested in using GitLab's new [elasticsearch repository indexer](../integration/elasticsearch.md#elasticsearch-repository-indexer-beta) (currently in beta)
please follow the instructions on the document linked above and enable the
indexer usage in the GitLab admin settings.
### 5. Start application
```bash
sudo service gitlab start
sudo service nginx restart
```
### 6. Check application status
Check if GitLab and its environment are configured correctly:
```bash
sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
```
To make sure you didn't miss anything run a more thorough check with:
```bash
sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
```
If all items are green, then congratulations upgrade complete!
## Things went south? Revert to previous version (Community Edition 11.4)
### 1. Revert the code to the previous version
```bash
cd /home/git/gitlab
sudo -u git -H git checkout 11-4-stable-ee
```
### 2. Restore from the backup
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
```
[support@gitlab.com]: mailto:support@gitlab.com
This diff is collapsed.
<script>
import { mapGetters, mapActions, mapState } from 'vuex';
import Chart from 'chart.js';
import { GlLoadingIcon } from '@gitlab-org/gitlab-ui';
import bp from '~/breakpoints';
import { getMonthNames } from '~/lib/utils/datetime_utility';
import EmptyState from './empty_state.vue';
......@@ -9,6 +10,7 @@ import { CHART_OPTNS, CHART_COLORS } from '../constants';
export default {
components: {
EmptyState,
GlLoadingIcon,
},
props: {
endpoint: {
......
......@@ -23,6 +23,10 @@ export default {
type: String,
required: true,
},
emptyDashboardHelpPath: {
type: String,
required: true,
},
},
computed: {
...mapState(['projects', 'projectTokens', 'isLoadingProjects']),
......@@ -95,11 +99,19 @@ export default {
{{ s__('OperationsDashboard|Add a project to the dashboard') }}
</h4>
<div class="col-12 d-flex justify-content-center">
<span class="js-sub-title mw-460 text-tertiary">
<span class="js-sub-title mw-460 text-tertiary text-left">
{{ s__(`OperationsDashboard|The operations dashboard provides a summary of each project's
operational health, including pipeline and alert status.`) }}
operational health, including pipeline and alert statuses.`) }}
</span>
</div>
<div class="col-12">
<a
:href="emptyDashboardHelpPath"
class="js-documentation-link btn btn-primary prepend-top-default append-bottom-default"
>
{{ __('View documentation') }}
</a>
</div>
</div>
<gl-loading-icon
v-else
......
......@@ -17,6 +17,7 @@ document.addEventListener(
listPath: this.$el.dataset.listPath,
addPath: this.$el.dataset.addPath,
emptyDashboardSvgPath: this.$el.dataset.emptyDashboardSvgPath,
emptyDashboardHelpPath: this.$el.dataset.emptyDashboardHelpPath,
},
});
},
......
......@@ -7,7 +7,6 @@ import Tab from '~/vue_shared/components/tabs/tab.vue';
import IssueModal from 'ee/vue_shared/security_reports/components/modal.vue';
import SecurityDashboardTable from './security_dashboard_table.vue';
import VulnerabilityCountList from './vulnerability_count_list.vue';
import SvgBlankState from '~/pipelines/components/blank_state.vue';
import Icon from '~/vue_shared/components/icon.vue';
import popover from '~/vue_shared/directives/popover';
......@@ -20,7 +19,6 @@ export default {
Icon,
IssueModal,
SecurityDashboardTable,
SvgBlankState,
Tab,
Tabs,
VulnerabilityCountList,
......@@ -30,14 +28,6 @@ export default {
type: String,
required: true,
},
emptyStateSvgPath: {
type: String,
required: true,
},
errorStateSvgPath: {
type: String,
required: true,
},
vulnerabilitiesEndpoint: {
type: String,
required: true,
......@@ -49,7 +39,7 @@ export default {
},
computed: {
...mapGetters('vulnerabilities', ['vulnerabilitiesCountByReportType']),
...mapState('vulnerabilities', ['hasError', 'modal']),
...mapState('vulnerabilities', ['modal']),
sastCount() {
return this.vulnerabilitiesCountByReportType('sast');
},
......@@ -96,50 +86,39 @@ export default {
<template>
<div>
<div class="flash-container"></div>
<svg-blank-state
v-if="hasError"
:svg-path="errorStateSvgPath"
:message="s__(`Security Reports|There was an error fetching the dashboard.
Please try again in a few moments or contact your support team.`)"
/>
<div v-else>
<vulnerability-count-list />
<tabs stop-propagation>
<tab active>
<template slot="title">
<span>{{ __('SAST') }}</span>
<span
v-if="sastCount"
class="badge badge-pill"
>
{{ sastCount }}
</span>
<span
v-popover="popoverOptions"
class="text-muted prepend-left-4"
:aria-label="__('help')"
>
<icon
name="question"
class="vertical-align-middle"
/>
</span>
</template>
<vulnerability-count-list />
<tabs stop-propagation>
<tab active>
<template slot="title">
<span>{{ __('SAST') }}</span>
<span
v-if="sastCount"
class="badge badge-pill"
>
{{ sastCount }}
</span>
<span
v-popover="popoverOptions"
class="text-muted prepend-left-4"
:aria-label="__('help')"
>
<icon
name="question"
class="vertical-align-middle"
/>
</span>
</template>
<security-dashboard-table
:empty-state-svg-path="emptyStateSvgPath"
/>
</tab>
</tabs>
<issue-modal
:modal="modal"
:can-create-issue-permission="true"
:can-create-feedback-permission="true"
@createNewIssue="createIssue({ vulnerability: modal.vulnerability })"
@dismissIssue="dismissVulnerability({ vulnerability: modal.vulnerability })"
@revertDismissIssue="undoDismissal({ vulnerability: modal.vulnerability })"
/>
</div>
<security-dashboard-table />
</tab>
</tabs>
<issue-modal
:modal="modal"
:can-create-issue-permission="true"
:can-create-feedback-permission="true"
@createNewIssue="createIssue({ vulnerability: modal.vulnerability })"
@dismissIssue="dismissVulnerability({ vulnerability: modal.vulnerability })"
@revertDismissIssue="undoDismissal({ vulnerability: modal.vulnerability })"
/>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState, mapGetters } from 'vuex';
import Pagination from '~/vue_shared/components/pagination_links.vue';
import SecurityDashboardTableRow from './security_dashboard_table_row.vue';
......@@ -11,6 +11,7 @@ export default {
},
computed: {
...mapState('vulnerabilities', ['vulnerabilities', 'pageInfo', 'isLoadingVulnerabilities']),
...mapGetters('vulnerabilities', ['dashboardListError']),
showPagination() {
return this.pageInfo && this.pageInfo.total;
},
......@@ -49,6 +50,16 @@ export default {
{{ s__('Reports|Confidence') }}
</div>
</div>
<div class="flash-container">
<div
v-if="dashboardListError"
class="flash-alert">
<div class="flash-text container-fluid container-limited limit-container-width">
{{ s__('Security Dashboard|Error fetching the vulnerability list. Please check your network connection and try again.') }}
</div>
</div>
</div>
<div v-if="isLoadingVulnerabilities">
<security-dashboard-table-row
......
......@@ -33,7 +33,7 @@ export default {
{{ severity }}
</div>
<div class="vulnerability-count-body">
<span v-if="isLoading">&nbsp;</span>
<span v-if="isLoading">&mdash;</span>
<span v-else>{{ count }}</span>
</div>
</div>
......
......@@ -11,7 +11,11 @@ export default {
VulnerabilityCount,
},
computed: {
...mapGetters('vulnerabilities', ['vulnerabilitiesCountBySeverity']),
...mapGetters('vulnerabilities', [
'vulnerabilitiesCountBySeverity',
'dashboardCountError',
'dashboardError',
]),
...mapState('vulnerabilities', ['isLoadingVulnerabilitiesCount']),
counts() {
return SEVERITIES.map(severity => {
......@@ -25,6 +29,16 @@ export default {
<template>
<div class="vulnerabilities-count-list">
<div class="flash-container">
<div
v-if="dashboardError"
class="flash-alert">
<div class="flash-text container-fluid container-limited limit-container-width">
{{ s__('Security Dashboard|Error fetching the dashboard data. Please check your network connection and try again.') }}
</div>
</div>
</div>
<div class="row">
<div
v-for="count in counts"
......@@ -38,6 +52,16 @@ export default {
/>
</div>
</div>
<div class="flash-container">
<div
v-if="dashboardCountError"
class="flash-alert">
<div class="flash-text container-fluid container-limited limit-container-width">
{{ s__('Security Dashboard|Error fetching the vulnerability counts. Please check your network connection and try again.') }}
</div>
</div>
</div>
</div>
</template>
......
......@@ -15,8 +15,6 @@ export default () => {
return createElement('group-security-dashboard-app', {
props: {
dashboardDocumentation: el.dataset.dashboardDocumentation,
errorStateSvgPath: el.dataset.errorStateSvgPath,
emptyStateSvgPath: el.dataset.emptyStateSvgPath,
vulnerabilitiesEndpoint: el.dataset.vulnerabilitiesEndpoint,
vulnerabilitiesCountEndpoint: el.dataset.vulnerabilitiesSummaryEndpoint,
},
......
......@@ -112,9 +112,16 @@ export const receiveCreateIssueSuccess = ({ commit }, payload) => {
commit(types.RECEIVE_CREATE_ISSUE_SUCCESS, payload);
};
export const receiveCreateIssueError = ({ commit }) => {
export const receiveCreateIssueError = ({ commit }, { flashError }) => {
commit(types.RECEIVE_CREATE_ISSUE_ERROR);
createFlash(s__('Security Reports|There was an error creating the issue.'));
if (flashError) {
createFlash(
s__('Security Reports|There was an error creating the issue.'),
'alert',
document.querySelector('.ci-table'),
);
}
};
export const dismissVulnerability = ({ dispatch }, { vulnerability, flashError }) => {
......@@ -152,7 +159,11 @@ export const receiveDismissVulnerabilitySuccess = ({ commit }, payload) => {
export const receiveDismissVulnerabilityError = ({ commit }, { flashError }) => {
commit(types.RECEIVE_DISMISS_VULNERABILITY_ERROR);
if (flashError) {
createFlash(s__('Security Reports|There was an error dismissing the issue.'));
createFlash(
s__('Security Reports|There was an error dismissing the vulnerability.'),
'alert',
document.querySelector('.ci-table'),
);
}
};
......@@ -185,7 +196,11 @@ export const receiveUndoDismissalSuccess = ({ commit }, payload) => {
export const receiveUndoDismissalError = ({ commit }, { flashError }) => {
commit(types.RECEIVE_UNDO_DISMISSAL_ERROR);
if (flashError) {
createFlash(s__('Security Reports|There was an error undoing this dismissal.'));
createFlash(
s__('Security Reports|There was an error undoing this dismissal.'),
'alert',
document.querySelector('.ci-table'),
);
}
};
......
......@@ -8,5 +8,11 @@ export const vulnerabilitiesCountByReportType = state => type => {
const counts = state.vulnerabilitiesCount[type];
return counts ? Object.values(counts).reduce(sum, 0) : 0;
};
export const dashboardError = state =>
state.errorLoadingVulnerabilities && state.errorLoadingVulnerabilitiesCount;
export const dashboardListError = state =>
state.errorLoadingVulnerabilities && !state.errorLoadingVulnerabilitiesCount;
export const dashboardCountError = state =>
!state.errorLoadingVulnerabilities && state.errorLoadingVulnerabilitiesCount;
export default () => {};
......@@ -9,7 +9,7 @@ export default {
},
[types.REQUEST_VULNERABILITIES](state) {
state.isLoadingVulnerabilities = true;
state.hasError = false;
state.errorLoadingVulnerabilities = false;
},
[types.RECEIVE_VULNERABILITIES_SUCCESS](state, payload) {
state.isLoadingVulnerabilities = false;
......@@ -18,14 +18,14 @@ export default {
},
[types.RECEIVE_VULNERABILITIES_ERROR](state) {
state.isLoadingVulnerabilities = false;
state.hasError = true;
state.errorLoadingVulnerabilities = true;
},
[types.SET_VULNERABILITIES_COUNT_ENDPOINT](state, payload) {
state.vulnerabilitiesCountEndpoint = payload;
},
[types.REQUEST_VULNERABILITIES_COUNT](state) {
state.isLoadingVulnerabilitiesCount = true;
state.hasError = false;
state.errorLoadingVulnerabilitiesCount = false;
},
[types.RECEIVE_VULNERABILITIES_COUNT_SUCCESS](state, payload) {
state.isLoadingVulnerabilitiesCount = false;
......@@ -33,7 +33,7 @@ export default {
},
[types.RECEIVE_VULNERABILITIES_COUNT_ERROR](state) {
state.isLoadingVulnerabilitiesCount = false;
state.hasError = true;
state.errorLoadingVulnerabilitiesCount = true;
},
[types.SET_MODAL_DATA](state, payload) {
const { vulnerability } = payload;
......
import { s__ } from '~/locale';
export default () => ({
hasError: false,
isLoadingVulnerabilities: true,
errorLoadingVulnerabilities: false,
isLoadingVulnerabilitiesCount: true,
errorLoadingVulnerabilitiesCount: false,
pageInfo: {},
vulnerabilities: [],
vulnerabilitiesCount: {},
......
.draft-note-component {
padding: $gl-padding-8 $gl_padding;
margin: 0;
background: $orange-50;
.note-actions {
margin-top: -26px;
}
p {
margin: 0;
}
......@@ -31,7 +26,6 @@
}
.timeline-entry {
padding-left: 40px;
background-color: transparent;
}
}
......@@ -47,7 +41,7 @@ button[disabled] {
}
.draft-note-header {
padding: $gl-padding-8 0;
padding: $gl-padding-8 $gl-padding;
display: flex;
justify-content: space-between;
align-items: center;
......@@ -85,14 +79,16 @@ button[disabled] {
}
.draft-note-actions {
padding: $gl-padding 56px $gl-padding-8;
padding: $gl-padding;
}
.discussion-body,
.diff-file {
.notes .note {
&.draft-note {
border-bottom: 0;
background-color: $white-light;
margin: 0 $gl-padding;
border: 1px solid $border-color;
.timeline-entry-inner {
padding-top: 0;
......
# frozen_string_literal: true
module Projects
module Settings
class OperationsController < Projects::ApplicationController
before_action :check_license
before_action :authorize_update_environment!, only: [:create, :update]
before_action :authorize_read_environment!, only: [:show]
def show
@tracing_settings ||= ProjectTracingSetting.for_project(@project)
end
def update
result = EE::TracingSettingService.new(project, current_user, operations_params).execute
render_result(result)
end
def create
result = EE::TracingSettingService.new(project, current_user, operations_params).execute
@tracing_setting = project.tracing_setting
render_result(result)
end
private
def render_result(result)
respond_to do |format|
format.html do
if result[:status] == :success
flash[:notice] = _('Your changes have been saved')
else
flash[:alert] = _('Unable to save your changes')
end
redirect_to project_settings_operations_path(@project)
end
end
end
def operations_params
params.require(:tracing_settings).permit(:external_url)
end
def check_license
render_404 unless @project.feature_available?(:tracing, current_user)
end
end
end
end
# frozen_string_literal: true
class Projects::TracingsController < Projects::ApplicationController
before_action :check_license
before_action :authorize_read_environment!, only: [:show]
def show
end
private
def check_license
render_404 unless @project.feature_available?(:tracing, current_user)
end
end
......@@ -9,7 +9,10 @@ module EE
override :sidebar_settings_paths
def sidebar_settings_paths
super + %w(audit_events#index)
super + %w[
audit_events#index
operations#show
]
end
override :sidebar_repository_paths
......@@ -17,6 +20,14 @@ module EE
super + %w(path_locks)
end
override :sidebar_operations_paths
def sidebar_operations_paths
super + %w[
tracings
feature_flags
]
end
override :get_project_nav_tabs
def get_project_nav_tabs(project, current_user)
nav_tabs = super
......
......@@ -5,7 +5,8 @@ module OperationsHelper
{
'add-path' => add_operations_project_path,
'list-path' => operations_list_path,
'empty-dashboard-svg-path' => image_path('illustrations/operations-dashboard_empty.svg')
'empty-dashboard-svg-path' => image_path('illustrations/operations-dashboard_empty.svg'),
'empty-dashboard-help-path' => help_page_path('user/operations_dashboard/index.html')
}
end
end
......@@ -25,6 +25,10 @@ module EE
where('EXISTS (?)', ::Ci::Build.latest.with_security_reports.where('ci_pipelines.id=ci_builds.commit_id').select(1))
end
scope :with_vulnerabilities, -> do
where('EXISTS (?)', ::Vulnerabilities::OccurrencePipeline.where('ci_pipelines.id=vulnerability_occurrence_pipelines.pipeline_id').select(1))
end
# This structure describes feature levels
# to access the file types for given reports
REPORT_LICENSED_FEATURES = {
......
......@@ -84,7 +84,7 @@ module EE
def latest_vulnerabilities
Vulnerabilities::Occurrence
.for_pipelines(all_pipelines.latest_successful_ids_per_project)
.for_pipelines(all_pipelines.with_vulnerabilities.latest_successful_ids_per_project)
end
def human_ldap_access
......
......@@ -30,6 +30,7 @@ module EE
has_one :jenkins_deprecated_service
has_one :github_service
has_one :gitlab_slack_application_service
has_one :tracing_setting, class_name: 'ProjectTracingSetting'
has_many :approvers, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :approver_groups, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
......@@ -109,6 +110,10 @@ module EE
end
end
def tracing_external_url
self.tracing_setting.try(:external_url)
end
def latest_pipeline_with_security_reports
pipelines.newest_first(ref: default_branch).with_security_reports.first ||
pipelines.newest_first(ref: default_branch).with_legacy_security_reports.first
......
......@@ -88,6 +88,7 @@ class License < ActiveRecord::Base
pseudonymizer
prometheus_alerts
operations_dashboard
tracing
].freeze
# List all features available for early adopters,
......
# frozen_string_literal: true
class ProjectTracingSetting < ActiveRecord::Base
belongs_to :project
validates :external_url, length: { maximum: 255 }, public_url: true
def self.create_or_update(project, params)
self.transaction(requires_new: true) do
tracing_setting = self.for_project(project)
tracing_setting.update(params)
end
rescue ActiveRecord::RecordNotUnique
retry
end
def self.for_project(project)
self.where(project: project).first_or_initialize
end
end
# frozen_string_literal: true
module EE
class TracingSettingService < BaseService
ValidationError = Class.new(StandardError)
def execute
# Convert an empty string in tracing_external_url to nil
if params.has_key?(:external_url)
params[:external_url] = params[:external_url].presence
end
# Delete the row in project_tracing_settings table if external_row is to be
# set to nil since that is currently the only value in the table.
if params[:external_url].nil?
destroy
else
create_or_update
end
rescue ValidationError => e
error(e.message)
end
def create_or_update
if ProjectTracingSetting.create_or_update(project, params)
success
else
update_failed
end
end
def destroy
tracing_setting = ProjectTracingSetting.for_project(project)
if tracing_setting.persisted? && tracing_setting.destroy
success
else
destroy_failed
end
end
def update_failed
model_errors = project.errors.full_messages.to_sentence
error_message = model_errors.presence || 'Project tracing settings could not be updated!'
error(error_message)
end
def destroy_failed
model_errors = project.errors.full_messages.to_sentence
error_message = model_errors.presence || 'Project tracing settings could not be deleted!'
error(error_message)
end
end
end
......@@ -3,6 +3,4 @@
#js-group-security-dashboard{ data: { vulnerabilities_endpoint: group_security_vulnerabilities_path(@group),
vulnerabilities_summary_endpoint: summary_group_security_vulnerabilities_path(@group),
dashboard_documentation: help_page_path('user/group/security_dashboard'),
empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
error_state_svg_path: image_path('illustrations/security-dashboard-api-error-empty-state.svg') } }
dashboard_documentation: help_page_path('user/group/security_dashboard') } }
- return unless @project.feature_available?(:tracing, current_user) && can?(current_user, :read_environment, @project)
- if project_nav_tab? :settings
= nav_link(controller: :tracings, action: [:show]) do
- if @project.tracing_external_url.present?
= link_to @project.tracing_external_url, target: "_blank", rel: 'noopener noreferrer' do
%span
= _('Tracing')
%i.strong.ml-1.fa.fa-external-link
- else
= link_to project_tracing_path(@project), title: _('Tracing') do
%span
= _('Tracing')
- @content_class = "limit-container-width" unless fluid_layout
- page_title _("Operations Settings")
- page_title _("Operations")
- has_jaeger_url = @project.tracing_external_url.present?
%section
%h4
= _("Jaeger tracing")
%p
- tracing_url = has_jaeger_url ? @project.tracing_external_url : project_tracing_path(@project)
- meta = has_jaeger_url ? 'rel="noopener noreferrer" target="_blank"' : ''
- icon = has_jaeger_url ? sprite_icon('external-link', size: 16, css_class: 'ml-1 vertical-align-middle') : ''
- tracing_start_tag = "<a href='#{tracing_url}' #{meta}>".html_safe
- tracing_end_tag = "#{icon}</a>".html_safe
= _("To open Jaeger and easily view tracing from GitLab, link the %{start_tag}Tracing%{end_tag} page to your server").html_safe % { start_tag: tracing_start_tag, end_tag: tracing_end_tag }
= form_for @tracing_settings, as: :tracing_settings, url: project_settings_operations_path(@project) do |f|
= form_errors(@tracing_settings)
.form-group
= f.label :external_url, _('Jaeger URL'), class: 'label-bold'
= f.url_field :external_url, class: 'form-control', placeholder: 'e.g. https://jaeger.mycompany.com'
%p.form-text.text-muted
- jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/"
- link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
- link_end_tag = "#{sprite_icon('external-link', size: 16, css_class: 'ml-1 vertical-align-middle')}</a>".html_safe
= _("For more information, please review %{link_start_tag}Jaeger's configuration doc%{link_end_tag}").html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
= f.submit _('Save changes'), class: 'btn btn-success'
- return unless @project.feature_available?(:tracing)
= nav_link(controller: [:operations]) do
= link_to project_settings_operations_path(@project), title: _('Operations') do
= _('Operations')
= link_to project_settings_operations_path(@project), title: _('Configure Tracing'), class: 'btn btn-success' do
= _('Add Jaeger URL')
- @content_class = "limit-container-width" unless fluid_layout
- page_title _("Tracing")
.border-top
.row.empty-state
.col-12
.svg-content
= image_tag 'illustrations/tracing_empty.svg', style: 'max-height: 254px'
.col-12
.text-content
%h4.text-left= _('Troubleshoot and monitor your application with tracing')
%p
- jaeger_help_url = "https://www.jaegertracing.io/docs/1.7/getting-started/"
- link_start_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jaeger_help_url }
- link_end_tag = "#{sprite_icon('external-link', size: 16, css_class: 'ml-1 vertical-align-middle')}</a>".html_safe
= _('To get started, link this page to your Jaeger server, or find out how to %{link_start_tag}install Jaeger%{link_end_tag}').html_safe % { link_start_tag: link_start_tag, link_end_tag: link_end_tag }
.text-center
= render 'tracing_button'
---
title: Add Tracing landing and settings page
merge_request: 7903
author:
type: added
---
title: Adds split error states for the group security dashboard
merge_request: 8208
author:
type: changed
---
title: Add documentation link to ops dashboard
merge_request: 8296
author:
type: changed
......@@ -8,6 +8,13 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
constraints: { project_id: Gitlab::PathRegex.project_route_regex },
module: :projects,
as: :project) do
resource :tracing, only: [:show]
namespace :settings do
resource :operations, only: [:show, :update, :create]
end
resources :autocomplete_sources, only: [] do
collection do
get 'epics'
......
# frozen_string_literal: true
class AddTracingSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :project_tracing_settings, id: :bigserial do |t|
t.timestamps_with_timezone null: false
t.references :project, null: false, foreign_key: { on_delete: :cascade }
t.string :external_url, null: false
t.index :project_id,
unique: true
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Projects::Settings::OperationsController do
set(:user) { create(:user) }
before do
sign_in(user)
end
describe 'GET show' do
shared_examples 'user without access to project' do |project_visibility|
let(:project) { create(:project, project_visibility) }
it 'returns 404' do
get :show, namespace_id: project.namespace, project_id: project
expect(response).to have_gitlab_http_status(:not_found)
end
end
shared_examples 'user with access to project' do |project_visibility|
let(:project) { create(:project, project_visibility) }
before do
project.add_reporter(user)
end
it 'renders ok' do
get :show, namespace_id: project.namespace, project_id: project
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template(:show)
end
end
shared_examples 'user needs to login' do |project_visibility|
it 'redirects for private project' do
project = create(:project, project_visibility)
get :show, namespace_id: project.namespace, project_id: project
expect(response).to redirect_to(new_user_session_path)
end
end
context 'with a license' do
before do
stub_licensed_features(tracing: true)
end
context 'when logged in with correct permission' do
it_behaves_like 'user with access to project', :public
it_behaves_like 'user with access to project', :private
it_behaves_like 'user with access to project', :internal
end
context 'when logged in without correct permission' do
it_behaves_like 'user without access to project', :public
it_behaves_like 'user without access to project', :private
it_behaves_like 'user without access to project', :internal
end
context 'when user not logged in' do
before do
sign_out(user)
end
it_behaves_like 'user without access to project', :public
it_behaves_like 'user needs to login', :private
it_behaves_like 'user needs to login', :internal
end
end
context 'without license' do
before do
stub_licensed_features(tracing: false)
end
it_behaves_like 'user without access to project', :public
it_behaves_like 'user without access to project', :private
it_behaves_like 'user without access to project', :internal
end
end
describe 'PATCH update' do
let(:public_project) { create(:project, :public) }
let(:private_project) { create(:project, :private) }
let(:internal_project) { create(:project, :internal) }
before do
public_project.add_maintainer(user)
private_project.add_maintainer(user)
internal_project.add_maintainer(user)
end
shared_examples 'user without write access' do |project_visibility|
let(:project) { create(:project, project_visibility) }
it 'does not update tracing external_url' do
update_project(project, external_url: 'https://gitlab.com')
expect(project.tracing_setting).to be_nil
end
end
context 'with a license' do
before do
stub_licensed_features(tracing: true)
end
shared_examples 'user with write access' do |project_visibility, value_to_set, value_to_check|
let(:project) { create(:project, project_visibility) }
before do
project.add_maintainer(user)
end
it 'updates tracing external_url' do
update_project(project, external_url: value_to_set)
expect(project.tracing_setting.external_url).to eq(value_to_check)
end
end
context 'with authorized user' do
it_behaves_like 'user with write access', :public, 'https://gitlab.com', 'https://gitlab.com'
it_behaves_like 'user with write access', :private, 'https://gitlab.com', 'https://gitlab.com'
it_behaves_like 'user with write access', :internal, 'https://gitlab.com', 'https://gitlab.com'
end
context 'with unauthorized user' do
it_behaves_like 'user without write access', :public
it_behaves_like 'user without write access', :private
it_behaves_like 'user without write access', :internal
end
context 'with anonymous user' do
before do
sign_out(user)
end
it_behaves_like 'user without write access', :public
it_behaves_like 'user without write access', :private
it_behaves_like 'user without write access', :internal
end
context 'with existing tracing_setting' do
let(:project) { create(:project) }
before do
project.create_tracing_setting!(external_url: 'https://gitlab.com')
project.add_maintainer(user)
end
it 'unsets external_url with nil' do
update_project(project, external_url: nil)
expect(project.tracing_setting).to be_nil
end
it 'unsets external_url with empty string' do
update_project(project, external_url: '')
expect(project.tracing_setting).to be_nil
end
it 'fails validation with invalid url' do
expect do
update_project(project, external_url: "invalid")
end.not_to change(project.tracing_setting, :external_url)
end
it 'does not set external_url if not present in params' do
expect do
update_project(project, some_param: 'some_value')
end.not_to change(project.tracing_setting, :external_url)
end
end
context 'without existing tracing_setting' do
let(:project) { create(:project) }
before do
project.add_maintainer(user)
end
it 'fails validation with invalid url' do
update_project(project, external_url: "invalid")
expect(project.tracing_setting).to be_nil
end
it 'does not set external_url if not present in params' do
update_project(project, some_param: 'some_value')
expect(project.tracing_setting).to be_nil
end
end
end
context 'without a license' do
before do
stub_licensed_features(tracing: false)
end
it_behaves_like 'user without write access', :public
it_behaves_like 'user without write access', :private
it_behaves_like 'user without write access', :internal
end
private
def project_params(project, params = {})
{ namespace_id: project.namespace, project_id: project, tracing_settings: params }
end
def update_project(project, params)
patch :update, project_params(project, params)
project.reload
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Projects::TracingsController do
set(:user) { create(:user) }
describe 'GET show' do
describe 'with valid license' do
before do
stub_licensed_features(tracing: true)
end
shared_examples 'authorized user' do |visibility_level|
let(:project) { create(:project, visibility_level) }
before do
project.add_reporter(user)
sign_in(user)
end
it 'renders OK' do
get :show, namespace_id: project.namespace, project_id: project
expect(response).to have_gitlab_http_status(200)
expect(response).to render_template(:show)
end
end
it_behaves_like 'authorized user', :public
it_behaves_like 'authorized user', :internal
it_behaves_like 'authorized user', :private
shared_examples 'unauthorized user' do |visibility_level|
let(:project) { create(:project, visibility_level) }
before do
sign_in(user)
end
it 'returns 404' do
get :show, namespace_id: project.namespace, project_id: project
expect(response).to have_gitlab_http_status(:not_found)
end
end
it_behaves_like 'unauthorized user', :public
it_behaves_like 'unauthorized user', :internal
it_behaves_like 'unauthorized user', :private
end
context 'with invalid license' do
before do
stub_licensed_features(tracing: false)
sign_in(user)
end
shared_examples 'invalid license' do |visibility_level|
let(:project) { create(:project, visibility_level) }
before do
stub_licensed_features(tracing: false)
project.add_reporter(user)
sign_in(user)
end
it 'returns 404' do
get :show, namespace_id: project.namespace, project_id: project
expect(response).to have_gitlab_http_status(:not_found)
end
end
it_behaves_like 'invalid license', :public
it_behaves_like 'invalid license', :internal
it_behaves_like 'invalid license', :private
end
end
end
......@@ -10,7 +10,8 @@ describe OperationsHelper do
expect(operations_data).to eq(
'add-path' => '/-/operations',
'list-path' => '/-/operations/list',
'empty-dashboard-svg-path' => '/images/illustrations/operations-dashboard_empty.svg'
'empty-dashboard-svg-path' => '/images/illustrations/operations-dashboard_empty.svg',
'empty-dashboard-help-path' => '/help/user/operations_dashboard/index.html'
)
end
end
......
......@@ -22,6 +22,7 @@ describe('dashboard component', () => {
addPath: 'mock-addPath',
listPath: mockListPath,
emptyDashboardSvgPath: '/assets/illustrations/operations-dashboard_empty.svg',
emptyDashboardHelpPath: '/help/user/operations_dashboard/index.html',
},
});
let vm;
......@@ -122,6 +123,18 @@ describe('dashboard component', () => {
mockText.EMPTY_SUBTITLE,
);
});
it('renders link to documentation', () => {
const link = vm.$el.querySelector('.js-documentation-link');
expect(link.innerText.trim()).toBe('View documentation');
});
it('links to documentation', () => {
const link = vm.$el.querySelector('.js-documentation-link');
expect(link.href).toMatch(vm.emptyDashboardHelpPath);
});
});
});
});
......@@ -5,7 +5,7 @@ export const mockText = {
DASHBOARD_TITLE: 'Operations Dashboard',
EMPTY_TITLE: 'Add a project to the dashboard',
EMPTY_SUBTITLE:
"The operations dashboard provides a summary of each project's operational health, including pipeline and alert status.",
"The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses.",
EMPTY_SVG_SOURCE: '/assets/illustrations/operations-dashboard_empty.svg',
NO_SEARCH_RESULTS: 'Sorry, no projects matched your search',
RECEIVE_PROJECTS_ERROR: 'Something went wrong, unable to get operations projects',
......
......@@ -48,4 +48,57 @@ describe('vulnerabilities module getters', () => {
expect(result).toBe(0);
});
});
describe('dashboardError', () => {
it('should return true when both error states exist', () => {
const errorLoadingVulnerabilities = true;
const errorLoadingVulnerabilitiesCount = true;
const state = { errorLoadingVulnerabilities, errorLoadingVulnerabilitiesCount };
const result = getters.dashboardError(state);
expect(result).toBe(true);
});
});
describe('dashboardCountError', () => {
it('should return true if the count error exists', () => {
const state = {
errorLoadingVulnerabilitiesCount: true,
};
const result = getters.dashboardCountError(state);
expect(result).toBe(true);
});
it('should return false if the list error exists as well', () => {
const state = {
errorLoadingVulnerabilities: true,
errorLoadingVulnerabilitiesCount: true,
};
const result = getters.dashboardCountError(state);
expect(result).toBe(false);
});
});
describe('dashboardListError', () => {
it('should return true when the list error exists', () => {
const state = {
errorLoadingVulnerabilities: true,
};
const result = getters.dashboardListError(state);
expect(result).toBe(true);
});
it('should return false if the count error exists as well', () => {
const state = {
errorLoadingVulnerabilities: true,
errorLoadingVulnerabilitiesCount: true,
};
const result = getters.dashboardListError(state);
expect(result).toBe(false);
});
});
});
......@@ -21,7 +21,7 @@ describe('vulnerabilities module mutations', () => {
beforeEach(() => {
state = {
...createState(),
hasError: true,
errorLoadingVulnerabilities: true,
};
mutations[types.REQUEST_VULNERABILITIES](state);
});
......@@ -30,8 +30,8 @@ describe('vulnerabilities module mutations', () => {
expect(state.isLoadingVulnerabilities).toBeTruthy();
});
it('should set `hasError` to `false`', () => {
expect(state.hasError).toBeFalsy();
it('should set `errorLoadingVulnerabilities` to `false`', () => {
expect(state.errorLoadingVulnerabilities).toBeFalsy();
});
});
......@@ -88,7 +88,7 @@ describe('vulnerabilities module mutations', () => {
beforeEach(() => {
state = {
...createState(),
hasError: true,
errorLoadingVulnerabilitiesCount: true,
};
mutations[types.REQUEST_VULNERABILITIES_COUNT](state);
});
......@@ -97,8 +97,8 @@ describe('vulnerabilities module mutations', () => {
expect(state.isLoadingVulnerabilitiesCount).toBeTruthy();
});
it('should set `hasError` to `false`', () => {
expect(state.hasError).toBeFalsy();
it('should set `errorLoadingVulnerabilitiesCount` to `false`', () => {
expect(state.errorLoadingVulnerabilitiesCount).toBeFalsy();
});
});
......
......@@ -67,6 +67,7 @@ project:
- software_license_policies
- project_registry
- packages
- tracing_setting
prometheus_metrics:
- project
- prometheus_alerts
......@@ -78,3 +79,5 @@ prometheus_alert_events:
epic_issues:
- issue
- epic
tracing_setting:
- project
---
ProjectTracingSetting:
- external_url
......@@ -40,6 +40,21 @@ describe Ci::Pipeline do
end
end
describe '#with_vulnerabilities scope' do
let!(:pipeline_1) { create(:ci_pipeline_without_jobs, project: project) }
let!(:pipeline_2) { create(:ci_pipeline_without_jobs, project: project) }
let!(:pipeline_3) { create(:ci_pipeline_without_jobs, project: project) }
before do
create(:vulnerabilities_occurrence, pipelines: [pipeline_1], project: pipeline.project)
create(:vulnerabilities_occurrence, pipelines: [pipeline_2], project: pipeline.project)
end
it "returns pipeline with vulnerabilities" do
expect(described_class.with_vulnerabilities).to contain_exactly(pipeline_1, pipeline_2)
end
end
shared_examples 'unlicensed report type' do
context 'when there is no licensed feature for artifact file type' do
it 'returns the artifact' do
......
# frozen_string_literal: true
require 'spec_helper'
describe ProjectTracingSetting do
describe '#external_url' do
let(:project) { build(:project) }
let(:tracing_setting) { project.build_tracing_setting }
it 'accepts a valid url' do
tracing_setting.external_url = "https://gitlab.com"
expect(tracing_setting).to be_valid
expect { tracing_setting.save! }.not_to raise_error
end
it 'fails with an invalid url' do
tracing_setting.external_url = "gitlab.com"
expect(tracing_setting).not_to be_valid
end
it 'fails with a blank string' do
tracing_setting.external_url = " "
expect(tracing_setting).not_to be_valid
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'layouts/nav/sidebar/_project' do
let(:project) { create(:project, :repository) }
before do
assign(:project, project)
assign(:repository, project.repository)
allow(view).to receive(:current_ref).and_return('master')
stub_licensed_features(tracing: true)
end
describe 'Operations > Tracing' do
it 'is not visible when no valid license' do
allow(view).to receive(:can?).and_return(true)
stub_licensed_features(tracing: false)
render
expect(rendered).not_to have_text 'Tracing'
end
it 'is not visible to unauthorized user' do
render
expect(rendered).not_to have_text 'Tracing'
end
context 'with project.tracing_external_url' do
let(:tracing_url) { 'https://tracing.url' }
before do
allow(view).to receive(:can?).and_return(true)
allow(project).to receive(:tracing_external_url).and_return(tracing_url)
end
it 'links to project.tracing_external_url' do
render
expect(rendered).to have_link('Tracing', href: tracing_url)
end
end
context 'without project.tracing_external_url' do
before do
allow(view).to receive(:can?).and_return(true)
allow(project).to receive(:tracing_external_url).and_return(nil)
end
it 'links to Tracing page' do
render
expect(rendered).to have_link('Tracing', href: project_tracing_path(project))
end
end
end
describe 'Settings > Operations' do
it 'is not visible when no valid license' do
allow(view).to receive(:can?).and_return(true)
stub_licensed_features(tracing: false)
render
expect(rendered).not_to have_link project_settings_operations_path(project)
end
it 'is not visible to unauthorized user' do
render
expect(rendered).not_to have_link project_settings_operations_path(project)
end
it 'links to settings page' do
allow(view).to receive(:can?).and_return(true)
render
expect(rendered).to have_link('Operations', href: project_settings_operations_path(project))
end
end
end
......@@ -399,6 +399,9 @@ msgstr ""
msgid "Add Group Webhooks and GitLab Enterprise Edition."
msgstr ""
msgid "Add Jaeger URL"
msgstr ""
msgid "Add Kubernetes cluster"
msgstr ""
......@@ -2192,6 +2195,9 @@ msgstr ""
msgid "Configure Gitaly timeouts."
msgstr ""
msgid "Configure Tracing"
msgstr ""
msgid "Configure automatic git checks and housekeeping on repositories."
msgstr ""
......@@ -3511,6 +3517,9 @@ msgstr ""
msgid "For more information, go to the "
msgstr ""
msgid "For more information, please review %{link_start_tag}Jaeger's configuration doc%{link_end_tag}"
msgstr ""
msgid "For more information, see the documentation on %{deactivating_usage_ping_link_start}deactivating the usage ping%{deactivating_usage_ping_link_end}."
msgstr ""
......@@ -4502,6 +4511,12 @@ msgstr ""
msgid "IssuesAnalytics|To widen your search, change or remove filters in the filter bar above"
msgstr ""
msgid "Jaeger URL"
msgstr ""
msgid "Jaeger tracing"
msgstr ""
msgid "Jan"
msgstr ""
......@@ -4666,6 +4681,9 @@ msgstr ""
msgid "Last edited by %{name}"
msgstr ""
msgid "Last reply by"
msgstr ""
msgid "Last update"
msgstr ""
......@@ -5516,6 +5534,9 @@ msgstr ""
msgid "Notes|Are you sure you want to cancel creating this comment?"
msgstr ""
msgid "Notes|Collapse replies"
msgstr ""
msgid "Notes|Show all activity"
msgstr ""
......@@ -5674,10 +5695,13 @@ msgstr ""
msgid "Operations Dashboard"
msgstr ""
msgid "Operations Settings"
msgstr ""
msgid "OperationsDashboard|Add a project to the dashboard"
msgstr ""
msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert status."
msgid "OperationsDashboard|The operations dashboard provides a summary of each project's operational health, including pipeline and alert statuses."
msgstr ""
msgid "OperationsDashboard|Unable to add %{invalidProjects}. The Operations Dashboard is available for projects with a Gold subscription."
......@@ -7118,6 +7142,15 @@ msgstr ""
msgid "Security Dashboard"
msgstr ""
msgid "Security Dashboard|Error fetching the dashboard data. Please check your network connection and try again."
msgstr ""
msgid "Security Dashboard|Error fetching the vulnerability counts. Please check your network connection and try again."
msgstr ""
msgid "Security Dashboard|Error fetching the vulnerability list. Please check your network connection and try again."
msgstr ""
msgid "Security Dashboard|Issue Created"
msgstr ""
......@@ -7139,15 +7172,9 @@ msgstr ""
msgid "Security Reports|There was an error creating the issue."
msgstr ""
msgid "Security Reports|There was an error dismissing the issue."
msgstr ""
msgid "Security Reports|There was an error dismissing the vulnerability."
msgstr ""
msgid "Security Reports|There was an error fetching the dashboard. Please try again in a few moments or contact your support team."
msgstr ""
msgid "Security Reports|There was an error undoing the dismissal."
msgstr ""
......@@ -8419,6 +8446,9 @@ msgstr ""
msgid "To get started you enter your FogBugz URL and login information below. In the next steps, you'll be able to map users and select the projects you want to import."
msgstr ""
msgid "To get started, link this page to your Jaeger server, or find out how to %{link_start_tag}install Jaeger%{link_end_tag}"
msgstr ""
msgid "To get started, please enter your Gitea Host URL and a %{link_to_personal_token}."
msgstr ""
......@@ -8443,6 +8473,9 @@ msgstr ""
msgid "To only use CI/CD features for an external repository, choose <strong>CI/CD for external repo</strong>."
msgstr ""
msgid "To open Jaeger and easily view tracing from GitLab, link the %{start_tag}Tracing%{end_tag} page to your server"
msgstr ""
msgid "To set up SAML authentication for your group through an identity provider like Azure, Okta, Onelogin, Ping Identity, or your custom SAML 2.0 provider:"
msgstr ""
......@@ -8515,6 +8548,9 @@ msgstr ""
msgid "Total: %{total}"
msgstr ""
msgid "Tracing"
msgstr ""
msgid "Track activity with Contribution Analytics."
msgstr ""
......@@ -8545,6 +8581,9 @@ msgstr ""
msgid "Triggers can force a specific branch or tag to get rebuilt with an API call. These tokens will impersonate their associated user including their access to projects and their project permissions."
msgstr ""
msgid "Troubleshoot and monitor your application with tracing"
msgstr ""
msgid "Try again"
msgstr ""
......@@ -8563,6 +8602,9 @@ msgstr ""
msgid "Unable to load the diff. %{button_try_again}"
msgstr ""
msgid "Unable to save your changes"
msgstr ""
msgid "Unable to sign you in to the group with SAML due to \"%{reason}\""
msgstr ""
......@@ -8791,6 +8833,9 @@ msgstr ""
msgid "View app"
msgstr ""
msgid "View documentation"
msgstr ""
msgid "View epics list"
msgstr ""
......@@ -9238,6 +9283,9 @@ msgstr ""
msgid "Your changes have been committed. Commit %{commitId} %{commitStats}"
msgstr ""
msgid "Your changes have been saved"
msgstr ""
msgid "Your comment will not be visible to the public."
msgstr ""
......@@ -9867,6 +9915,11 @@ msgstr ""
msgid "remove weight"
msgstr ""
msgid "reply"
msgid_plural "replies"
msgstr[0] ""
msgstr[1] ""
msgid "source"
msgstr ""
......
......@@ -3,6 +3,7 @@ import createStore from '~/notes/stores';
import noteableDiscussion from '~/notes/components/noteable_discussion.vue';
import '~/behaviors/markdown/render_gfm';
import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data';
import mockDiffFile from '../../diffs/mock_data/diff_file';
const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json';
......@@ -33,9 +34,20 @@ describe('noteable_discussion component', () => {
expect(vm.$el.querySelector('.user-avatar-link')).not.toBeNull();
});
it('should not render discussion header for non diff discussions', () => {
expect(vm.$el.querySelector('.discussion-header')).toBeNull();
});
it('should render discussion header', () => {
expect(vm.$el.querySelector('.discussion-header')).not.toBeNull();
expect(vm.$el.querySelector('.notes').children.length).toEqual(discussionMock.notes.length);
const discussion = { ...discussionMock };
discussion.diff_file = mockDiffFile;
discussion.diff_discussion = true;
const diffDiscussionVm = new Component({
store,
propsData: { discussion },
}).$mount();
expect(diffDiscussionVm.$el.querySelector('.discussion-header')).not.toBeNull();
});
describe('actions', () => {
......
import Vue from 'vue';
import toggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { note } from '../mock_data';
const deepCloneObject = obj => JSON.parse(JSON.stringify(obj));
describe('toggle replies widget for notes', () => {
let vm;
let ToggleRepliesWidget;
const noteFromOtherUser = deepCloneObject(note);
noteFromOtherUser.author.username = 'fatihacet';
const noteFromAnotherUser = deepCloneObject(note);
noteFromAnotherUser.author.username = 'mgreiling';
noteFromAnotherUser.author.name = 'Mike Greiling';
const replies = [note, note, note, noteFromOtherUser, noteFromAnotherUser];
beforeEach(() => {
ToggleRepliesWidget = Vue.extend(toggleRepliesWidget);
});
afterEach(() => {
vm.$destroy();
});
describe('collapsed state', () => {
beforeEach(() => {
vm = mountComponent(ToggleRepliesWidget, {
replies,
collapsed: true,
});
});
it('should render the collapsed', () => {
const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' ');
expect(vm.$el.classList.contains('collapsed')).toEqual(true);
expect(vm.$el.querySelectorAll('.user-avatar-link').length).toEqual(3);
expect(vm.$el.querySelector('time')).not.toBeNull();
expect(vmTextContent).toContain('5 replies');
expect(vmTextContent).toContain(`Last reply by ${noteFromAnotherUser.author.name}`);
});
it('should emit toggle event when the replies text clicked', () => {
const spy = spyOn(vm, '$emit');
vm.$el.querySelector('.js-replies-text').click();
expect(spy).toHaveBeenCalledWith('toggle');
});
});
describe('expanded state', () => {
beforeEach(() => {
vm = mountComponent(ToggleRepliesWidget, {
replies,
collapsed: false,
});
});
it('should render expanded state', () => {
const vmTextContent = vm.$el.textContent.replace(/\s\s+/g, ' ');
expect(vm.$el.querySelector('.collapse-replies-btn')).not.toBeNull();
expect(vmTextContent).toContain('Collapse replies');
});
it('should emit toggle event when the collapse replies text called', () => {
const spy = spyOn(vm, '$emit');
vm.$el.querySelector('.js-collapse-replies').click();
expect(spy).toHaveBeenCalledWith('toggle');
});
});
});
......@@ -23,15 +23,23 @@ describe 'Import/Export attribute configuration' do
let(:safe_attributes_file) { 'spec/lib/gitlab/import_export/safe_model_attributes.yml' }
let(:safe_model_attributes) { YAML.load_file(safe_attributes_file) }
let(:ee_safe_attributes_file) { 'ee/spec/lib/gitlab/import_export/safe_model_attributes.yml' }
let(:ee_safe_model_attributes) { File.exist?(ee_safe_attributes_file) ? YAML.load_file(ee_safe_attributes_file) : {} }
it 'has no new columns' do
relation_names.each do |relation_name|
relation_class = relation_class_for_name(relation_name)
relation_attributes = relation_class.new.attributes.keys
expect(safe_model_attributes[relation_class.to_s]).not_to be_nil, "Expected exported class #{relation_class} to exist in safe_model_attributes"
current_attributes = parsed_attributes(relation_name, relation_attributes)
safe_attributes = safe_model_attributes[relation_class.to_s]
safe_attributes = safe_model_attributes[relation_class.to_s].dup || []
ee_safe_model_attributes[relation_class.to_s].to_a.each do |attribute|
safe_attributes << attribute
end
expect(safe_attributes).not_to be_nil, "Expected exported class #{relation_class} to exist in safe_model_attributes"
new_attributes = current_attributes - safe_attributes
expect(new_attributes).to be_empty, failure_message(relation_class.to_s, new_attributes)
......@@ -43,6 +51,7 @@ describe 'Import/Export attribute configuration' do
It looks like #{relation_class}, which is exported using the project Import/Export, has new attributes: #{new_attributes.join(',')}
Please add the attribute(s) to SAFE_MODEL_ATTRIBUTES if you consider this can be exported.
#{"If the model/associations are EE-specific, use `#{File.expand_path(ee_safe_attributes_file)}`.\n" if ee_safe_model_attributes.any?}
Otherwise, please blacklist the attribute(s) in IMPORT_EXPORT_CONFIG by adding it to its correspondent
model in the +excluded_attributes+ section.
......
......@@ -150,17 +150,25 @@ shared_examples 'discussion comments' do |resource_name|
end
if resource_name == 'merge request'
let(:note_id) { find("#{comments_selector} .note", match: :first)['data-note-id'] }
let(:note_id) { find("#{comments_selector} .note:first-child", match: :first)['data-note-id'] }
let(:reply_id) { find("#{comments_selector} .note:last-child", match: :first)['data-note-id'] }
it 'shows resolved discussion when toggled' do
find("#{comments_selector} .js-vue-discussion-reply").click
find("#{comments_selector} .note-textarea").send_keys('a')
click_button "Comment"
wait_for_requests
click_button "Resolve discussion"
wait_for_requests
expect(page).to have_selector(".note-row-#{note_id}", visible: true)
refresh
click_button "Toggle discussion"
click_button "1 reply"
expect(page).to have_selector(".note-row-#{note_id}", visible: true)
expect(page).to have_selector(".note-row-#{reply_id}", visible: true)
end
end
end
......
......@@ -162,8 +162,9 @@ module TestEnv
version: Gitlab::GitalyClient.expected_server_version,
task: "gitlab:gitaly:install[#{gitaly_dir},#{repos_path}]") do
start_gitaly(gitaly_dir)
end
Gitlab::SetupHelper.create_gitaly_configuration(gitaly_dir, { 'default' => repos_path }, force: true)
start_gitaly(gitaly_dir)
end
end
def start_gitaly(gitaly_dir)
......
......@@ -8,29 +8,29 @@ describe StuckImportJobsWorker do
context 'when the import status was already updated' do
before do
allow(Gitlab::SidekiqStatus).to receive(:completed_jids) do
project.import_start
project.import_finish
import_state.start
import_state.finish
[project.import_jid]
[import_state.jid]
end
end
it 'does not mark the project as failed' do
worker.perform
expect(project.reload.import_status).to eq('finished')
expect(import_state.reload.status).to eq('finished')
end
end
context 'when the import status was not updated' do
before do
allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([project.import_jid])
allow(Gitlab::SidekiqStatus).to receive(:completed_jids).and_return([import_state.jid])
end
it 'marks the project as failed' do
worker.perform
expect(project.reload.import_status).to eq('failed')
expect(import_state.reload.status).to eq('failed')
end
end
end
......@@ -41,27 +41,27 @@ describe StuckImportJobsWorker do
end
it 'does not mark the project as failed' do
expect { worker.perform }.not_to change { project.reload.import_status }
expect { worker.perform }.not_to change { import_state.reload.status }
end
end
end
describe 'with scheduled import_status' do
it_behaves_like 'project import job detection' do
let(:project) { create(:project, :import_scheduled) }
let(:import_state) { create(:project, :import_scheduled).import_state }
before do
project.import_state.update(jid: '123')
import_state.update(jid: '123')
end
end
end
describe 'with started import_status' do
it_behaves_like 'project import job detection' do
let(:project) { create(:project, :import_started) }
let(:import_state) { create(:project, :import_started).import_state }
before do
project.import_state.update(jid: '123')
import_state.update(jid: '123')
end
end
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment