Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
4cb5e5011a
commit
286fe61013
121 changed files with 23623 additions and 22031 deletions
2
Gemfile
2
Gemfile
|
@ -87,7 +87,7 @@ gem 'grape-entity', '~> 0.7.1'
|
|||
gem 'rack-cors', '~> 1.0.6', require: 'rack/cors'
|
||||
|
||||
# GraphQL API
|
||||
gem 'graphql', '~> 1.9.12'
|
||||
gem 'graphql', '~> 1.9.19'
|
||||
# NOTE: graphiql-rails v1.5+ doesn't work: https://gitlab.com/gitlab-org/gitlab/issues/31771
|
||||
# TODO: remove app/views/graphiql/rails/editors/show.html.erb when https://github.com/rmosolgo/graphiql-rails/pull/71 is released:
|
||||
# https://gitlab.com/gitlab-org/gitlab/issues/31747
|
||||
|
|
|
@ -456,7 +456,7 @@ GEM
|
|||
graphiql-rails (1.4.10)
|
||||
railties
|
||||
sprockets-rails
|
||||
graphql (1.9.12)
|
||||
graphql (1.9.19)
|
||||
graphql-docs (1.6.0)
|
||||
commonmarker (~> 0.16)
|
||||
escape_utils (~> 1.2)
|
||||
|
@ -1252,7 +1252,7 @@ DEPENDENCIES
|
|||
grape-path-helpers (~> 1.2)
|
||||
grape_logging (~> 1.7)
|
||||
graphiql-rails (~> 1.4.10)
|
||||
graphql (~> 1.9.12)
|
||||
graphql (~> 1.9.19)
|
||||
graphql-docs (~> 1.6.0)
|
||||
grpc (~> 1.24.0)
|
||||
gssapi
|
||||
|
|
|
@ -492,41 +492,6 @@ const Api = {
|
|||
buildUrl(url) {
|
||||
return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version));
|
||||
},
|
||||
|
||||
/**
|
||||
* Returns pods logs for an environment with an optional pod and container
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Object} param.environment - Environment object
|
||||
* @param {string=} params.podName - Pod name, if not set the backend assumes a default one
|
||||
* @param {string=} params.containerName - Container name, if not set the backend assumes a default one
|
||||
* @param {string=} params.start - Starting date to query the logs in ISO format
|
||||
* @param {string=} params.end - Ending date to query the logs in ISO format
|
||||
* @returns {Promise} Axios promise for the result of a GET request of logs
|
||||
*/
|
||||
getPodLogs({ environment, podName, containerName, search, start, end }) {
|
||||
const url = this.buildUrl(environment.logs_api_path);
|
||||
|
||||
const params = {};
|
||||
|
||||
if (podName) {
|
||||
params.pod_name = podName;
|
||||
}
|
||||
if (containerName) {
|
||||
params.container_name = containerName;
|
||||
}
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
if (start) {
|
||||
params.start = start;
|
||||
}
|
||||
if (end) {
|
||||
params.end = end;
|
||||
}
|
||||
|
||||
return axios.get(url, { params });
|
||||
},
|
||||
};
|
||||
|
||||
export default Api;
|
||||
|
|
|
@ -24,25 +24,19 @@ export default {
|
|||
discardModalTitle() {
|
||||
return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path });
|
||||
},
|
||||
actionButtonText() {
|
||||
return this.activeFile.staged ? __('Unstage') : __('Stage');
|
||||
},
|
||||
isStaged() {
|
||||
return !this.activeFile.changed && this.activeFile.staged;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions(['stageChange', 'unstageChange', 'discardFileChanges']),
|
||||
actionButtonClicked() {
|
||||
if (this.activeFile.staged) {
|
||||
this.unstageChange(this.activeFile.path);
|
||||
} else {
|
||||
this.stageChange(this.activeFile.path);
|
||||
}
|
||||
},
|
||||
showDiscardModal() {
|
||||
this.$refs.discardModal.show();
|
||||
},
|
||||
discardChanges(path) {
|
||||
this.unstageChange(path);
|
||||
this.discardFileChanges(path);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -65,19 +59,7 @@ export default {
|
|||
class="btn btn-remove btn-inverted append-right-8"
|
||||
@click="showDiscardModal"
|
||||
>
|
||||
{{ __('Discard') }}
|
||||
</button>
|
||||
<button
|
||||
ref="actionButton"
|
||||
:class="{
|
||||
'btn-success': !isStaged,
|
||||
'btn-warning': isStaged,
|
||||
}"
|
||||
type="button"
|
||||
class="btn btn-inverted"
|
||||
@click="actionButtonClicked"
|
||||
>
|
||||
{{ actionButtonText }}
|
||||
{{ __('Discard changes') }}
|
||||
</button>
|
||||
</div>
|
||||
<gl-modal
|
||||
|
@ -87,7 +69,7 @@ export default {
|
|||
:ok-title="__('Discard changes')"
|
||||
:modal-id="discardModalId"
|
||||
:title="discardModalTitle"
|
||||
@ok="discardFileChanges(activeFile.path)"
|
||||
@ok="discardChanges(activeFile.path)"
|
||||
>
|
||||
{{ __("You will lose all changes you've made to this file. This action cannot be undone.") }}
|
||||
</gl-modal>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { mapState, mapActions, mapGetters } from 'vuex';
|
||||
import { sprintf, __ } from '~/locale';
|
||||
import { n__, __ } from '~/locale';
|
||||
import LoadingButton from '~/vue_shared/components/loading_button.vue';
|
||||
import CommitMessageField from './message_field.vue';
|
||||
import Actions from './actions.vue';
|
||||
|
@ -26,15 +26,7 @@ export default {
|
|||
...mapGetters(['hasChanges']),
|
||||
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
|
||||
overviewText() {
|
||||
return sprintf(
|
||||
__(
|
||||
'<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes',
|
||||
),
|
||||
{
|
||||
stagedFilesLength: this.stagedFiles.length,
|
||||
changedFilesLength: this.changedFiles.length,
|
||||
},
|
||||
);
|
||||
return n__('%d changed file', '%d changed files', this.stagedFiles.length);
|
||||
},
|
||||
commitButtonText() {
|
||||
return this.stagedFiles.length ? __('Commit') : __('Stage & Commit');
|
||||
|
@ -125,7 +117,7 @@ export default {
|
|||
>
|
||||
{{ __('Commit…') }}
|
||||
</button>
|
||||
<p class="text-center" v-html="overviewText"></p>
|
||||
<p class="text-center bold">{{ overviewText }}</p>
|
||||
</div>
|
||||
<form v-if="!isCompact" ref="formEl" @submit.prevent.stop="commitChanges">
|
||||
<transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition>
|
||||
|
|
|
@ -17,10 +17,6 @@ export default {
|
|||
tooltip,
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
fileList: {
|
||||
type: Array,
|
||||
required: true,
|
||||
|
@ -29,18 +25,6 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
action: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
actionBtnText: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
actionBtnIcon: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
stagedList: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
|
@ -63,9 +47,9 @@ export default {
|
|||
},
|
||||
computed: {
|
||||
titleText() {
|
||||
return sprintf(__('%{title} changes'), {
|
||||
title: this.title,
|
||||
});
|
||||
if (!this.title) return __('Changes');
|
||||
|
||||
return sprintf(__('%{title} changes'), { title: this.title });
|
||||
},
|
||||
filesLength() {
|
||||
return this.fileList.length;
|
||||
|
@ -73,17 +57,16 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']),
|
||||
actionBtnClicked() {
|
||||
this[this.action]();
|
||||
|
||||
$(this.$refs.actionBtn).tooltip('hide');
|
||||
},
|
||||
openDiscardModal() {
|
||||
$('#discard-all-changes').modal('show');
|
||||
},
|
||||
unstageAndDiscardAllChanges() {
|
||||
this.unstageAllChanges();
|
||||
this.discardAllChanges();
|
||||
},
|
||||
},
|
||||
discardModalText: __(
|
||||
"You will lose all the unstaged changes you've made in this project. This action cannot be undone.",
|
||||
"You will lose all uncommitted changes you've made in this project. This action cannot be undone.",
|
||||
),
|
||||
};
|
||||
</script>
|
||||
|
@ -95,24 +78,6 @@ export default {
|
|||
<icon v-once :name="iconName" :size="18" class="append-right-8" />
|
||||
<strong> {{ titleText }} </strong>
|
||||
<div class="d-flex ml-auto">
|
||||
<button
|
||||
ref="actionBtn"
|
||||
v-tooltip
|
||||
:title="actionBtnText"
|
||||
:aria-label="actionBtnText"
|
||||
:disabled="!filesLength"
|
||||
:class="{
|
||||
'disabled-content': !filesLength,
|
||||
}"
|
||||
type="button"
|
||||
class="d-flex ide-staged-action-btn p-0 border-0 align-items-center"
|
||||
data-placement="bottom"
|
||||
data-container="body"
|
||||
data-boundary="viewport"
|
||||
@click="actionBtnClicked"
|
||||
>
|
||||
<icon :name="actionBtnIcon" :size="16" class="ml-auto mr-auto" />
|
||||
</button>
|
||||
<button
|
||||
v-if="!stagedList"
|
||||
v-tooltip
|
||||
|
@ -151,9 +116,9 @@ export default {
|
|||
v-if="!stagedList"
|
||||
id="discard-all-changes"
|
||||
:footer-primary-button-text="__('Discard all changes')"
|
||||
:header-title-text="__('Discard all unstaged changes?')"
|
||||
:header-title-text="__('Discard all changes?')"
|
||||
footer-primary-button-variant="danger"
|
||||
@submit="discardAllChanges"
|
||||
@submit="unstageAndDiscardAllChanges"
|
||||
>
|
||||
{{ $options.discardModalText }}
|
||||
</gl-modal>
|
||||
|
|
|
@ -57,13 +57,7 @@ export default {
|
|||
},
|
||||
},
|
||||
methods: {
|
||||
...mapActions([
|
||||
'discardFileChanges',
|
||||
'updateViewer',
|
||||
'openPendingTab',
|
||||
'unstageChange',
|
||||
'stageChange',
|
||||
]),
|
||||
...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']),
|
||||
openFileInEditor() {
|
||||
if (this.file.type === 'tree') return null;
|
||||
|
||||
|
@ -76,13 +70,6 @@ export default {
|
|||
}
|
||||
});
|
||||
},
|
||||
fileAction() {
|
||||
if (this.file.staged) {
|
||||
this.unstageChange(this.file.path);
|
||||
} else {
|
||||
this.stageChange(this.file.path);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -97,7 +84,6 @@ export default {
|
|||
}"
|
||||
class="multi-file-commit-list-path w-100 border-0 ml-0 mr-0"
|
||||
role="button"
|
||||
@dblclick="fileAction"
|
||||
@click="openFileInEditor"
|
||||
>
|
||||
<span class="multi-file-commit-list-file-path d-flex align-items-center">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { n__, __, sprintf } from '~/locale';
|
||||
import { n__ } from '~/locale';
|
||||
import tooltip from '~/vue_shared/directives/tooltip';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
|
||||
|
@ -49,16 +49,7 @@ export default {
|
|||
folderChangesTooltip() {
|
||||
if (this.changesCount === 0) return undefined;
|
||||
|
||||
if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) {
|
||||
return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount);
|
||||
} else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) {
|
||||
return n__('%d staged change', '%d staged changes', this.folderStagedCount);
|
||||
}
|
||||
|
||||
return sprintf(__('%{staged} staged and %{unstaged} unstaged changes'), {
|
||||
unstaged: this.folderUnstagedCount,
|
||||
staged: this.folderStagedCount,
|
||||
});
|
||||
return n__('%d changed file', '%d changed files', this.changesCount);
|
||||
},
|
||||
showTreeChangesCount() {
|
||||
return this.isTree && this.changesCount > 0 && !this.file.opened;
|
||||
|
|
|
@ -86,28 +86,12 @@ export default {
|
|||
</deprecated-modal>
|
||||
<template v-if="showStageUnstageArea">
|
||||
<commit-files-list
|
||||
:title="__('Unstaged')"
|
||||
:key-prefix="$options.stageKeys.unstaged"
|
||||
:file-list="changedFiles"
|
||||
:action-btn-text="__('Stage all changes')"
|
||||
:active-file-key="activeFileKey"
|
||||
:empty-state-text="__('There are no unstaged changes')"
|
||||
action="stageAllChanges"
|
||||
action-btn-icon="stage-all"
|
||||
class="is-first"
|
||||
icon-name="unstaged"
|
||||
/>
|
||||
<commit-files-list
|
||||
:title="__('Staged')"
|
||||
:key-prefix="$options.stageKeys.staged"
|
||||
:file-list="stagedFiles"
|
||||
:action-btn-text="__('Unstage all changes')"
|
||||
:staged-list="true"
|
||||
:active-file-key="activeFileKey"
|
||||
:empty-state-text="__('There are no staged changes')"
|
||||
action="unstageAllChanges"
|
||||
action-btn-icon="unstage-all"
|
||||
icon-name="staged"
|
||||
:empty-state-text="__('There are no changes')"
|
||||
class="is-first"
|
||||
icon-name="unstaged"
|
||||
/>
|
||||
</template>
|
||||
<empty-state v-if="unusedSeal" />
|
||||
|
|
|
@ -1,23 +1,37 @@
|
|||
<script>
|
||||
import { throttle } from 'lodash';
|
||||
import { mapActions, mapState, mapGetters } from 'vuex';
|
||||
import { GlDropdown, GlDropdownItem, GlFormGroup, GlSearchBoxByClick, GlAlert } from '@gitlab/ui';
|
||||
import {
|
||||
GlSprintf,
|
||||
GlAlert,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlFormGroup,
|
||||
GlSearchBoxByClick,
|
||||
GlInfiniteScroll,
|
||||
} from '@gitlab/ui';
|
||||
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
|
||||
import { scrollDown } from '~/lib/utils/scroll_utils';
|
||||
import LogControlButtons from './log_control_buttons.vue';
|
||||
|
||||
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
|
||||
import { timeRangeFromUrl } from '~/monitoring/utils';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlSprintf,
|
||||
GlAlert,
|
||||
GlDropdown,
|
||||
GlDropdownItem,
|
||||
GlFormGroup,
|
||||
GlSearchBoxByClick,
|
||||
GlInfiniteScroll,
|
||||
DateTimePicker,
|
||||
LogControlButtons,
|
||||
},
|
||||
filters: {
|
||||
formatDate,
|
||||
},
|
||||
props: {
|
||||
environmentName: {
|
||||
type: String,
|
||||
|
@ -39,11 +53,13 @@ export default {
|
|||
required: true,
|
||||
},
|
||||
},
|
||||
traceHeight: 600,
|
||||
data() {
|
||||
return {
|
||||
searchQuery: '',
|
||||
timeRanges,
|
||||
isElasticStackCalloutDismissed: false,
|
||||
scrollDownButtonDisabled: true,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -52,7 +68,7 @@ export default {
|
|||
|
||||
timeRangeModel: {
|
||||
get() {
|
||||
return this.timeRange.current;
|
||||
return this.timeRange.selected;
|
||||
},
|
||||
set(val) {
|
||||
this.setTimeRange(val);
|
||||
|
@ -60,7 +76,7 @@ export default {
|
|||
},
|
||||
|
||||
showLoader() {
|
||||
return this.logs.isLoading || !this.logs.isComplete;
|
||||
return this.logs.isLoading;
|
||||
},
|
||||
advancedFeaturesEnabled() {
|
||||
const environment = this.environments.options.find(
|
||||
|
@ -75,16 +91,6 @@ export default {
|
|||
return !this.isElasticStackCalloutDismissed && this.disableAdvancedControls;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
trace(val) {
|
||||
this.$nextTick(() => {
|
||||
if (val) {
|
||||
scrollDown();
|
||||
}
|
||||
this.$refs.scrollButtons.update();
|
||||
});
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setInitData({
|
||||
timeRange: timeRangeFromUrl() || defaultTimeRange,
|
||||
|
@ -102,12 +108,26 @@ export default {
|
|||
'showPodLogs',
|
||||
'showEnvironment',
|
||||
'fetchEnvironments',
|
||||
'fetchMoreLogsPrepend',
|
||||
]),
|
||||
|
||||
topReached() {
|
||||
if (!this.logs.isLoading) {
|
||||
this.fetchMoreLogsPrepend();
|
||||
}
|
||||
},
|
||||
scrollDown() {
|
||||
this.$refs.infiniteScroll.scrollDown();
|
||||
},
|
||||
scroll: throttle(function scrollThrottled({ target = {} }) {
|
||||
const { scrollTop = 0, clientHeight = 0, scrollHeight = 0 } = target;
|
||||
this.scrollDownButtonDisabled = scrollTop + clientHeight === scrollHeight;
|
||||
}, 200),
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="build-page-pod-logs mt-3">
|
||||
<div class="environment-logs-viewer mt-3">
|
||||
<gl-alert
|
||||
v-if="shouldShowElasticStackCallout"
|
||||
class="mb-3 js-elasticsearch-alert"
|
||||
|
@ -209,14 +229,50 @@ export default {
|
|||
<log-control-buttons
|
||||
ref="scrollButtons"
|
||||
class="controllers align-self-end mb-1"
|
||||
:scroll-down-button-disabled="scrollDownButtonDisabled"
|
||||
@refresh="showPodLogs(pods.current)"
|
||||
@scrollDown="scrollDown"
|
||||
/>
|
||||
</div>
|
||||
<pre class="build-trace js-log-trace"><code class="bash js-build-output">{{trace}}
|
||||
<div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div></code></pre>
|
||||
|
||||
<gl-infinite-scroll
|
||||
ref="infiniteScroll"
|
||||
class="log-lines"
|
||||
:style="{ height: `${$options.traceHeight}px` }"
|
||||
:max-list-height="$options.traceHeight"
|
||||
:fetched-items="logs.lines.length"
|
||||
@topReached="topReached"
|
||||
@scroll="scroll"
|
||||
>
|
||||
<template #items>
|
||||
<pre
|
||||
class="build-trace js-log-trace"
|
||||
><code class="bash js-build-output"><div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
<div class="dot"></div>
|
||||
</div>{{trace}}
|
||||
</code></pre>
|
||||
</template>
|
||||
<template #default
|
||||
><div></div
|
||||
></template>
|
||||
</gl-infinite-scroll>
|
||||
|
||||
<div ref="logFooter" class="log-footer py-2 px-3">
|
||||
<gl-sprintf :message="s__('Environments|Logs from %{start} to %{end}.')">
|
||||
<template #start>{{ timeRange.current.start | formatDate }}</template>
|
||||
<template #end>{{ timeRange.current.end | formatDate }}</template>
|
||||
</gl-sprintf>
|
||||
<gl-sprintf
|
||||
v-if="!logs.isComplete"
|
||||
:message="s__('Environments|Currently showing %{fetched} results.')"
|
||||
>
|
||||
<template #fetched>{{ logs.lines.length }}</template>
|
||||
</gl-sprintf>
|
||||
<template v-else>
|
||||
{{ s__('Environments|Currently showing all results.') }}</template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,12 +1,5 @@
|
|||
<script>
|
||||
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
|
||||
import {
|
||||
canScroll,
|
||||
isScrolledToTop,
|
||||
isScrolledToBottom,
|
||||
scrollDown,
|
||||
scrollUp,
|
||||
} from '~/lib/utils/scroll_utils';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
|
||||
export default {
|
||||
|
@ -17,32 +10,34 @@ export default {
|
|||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
},
|
||||
props: {
|
||||
scrollUpButtonDisabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
scrollDownButtonDisabled: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
scrollToTopEnabled: false,
|
||||
scrollToBottomEnabled: false,
|
||||
scrollUpAvailable: Boolean(this.$listeners.scrollUp),
|
||||
scrollDownAvailable: Boolean(this.$listeners.scrollDown),
|
||||
};
|
||||
},
|
||||
created() {
|
||||
window.addEventListener('scroll', this.update);
|
||||
},
|
||||
destroyed() {
|
||||
window.removeEventListener('scroll', this.update);
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Checks if page can be scrolled and updates
|
||||
* enabled/disabled state of buttons accordingly
|
||||
*/
|
||||
update() {
|
||||
this.scrollToTopEnabled = canScroll() && !isScrolledToTop();
|
||||
this.scrollToBottomEnabled = canScroll() && !isScrolledToBottom();
|
||||
},
|
||||
handleRefreshClick() {
|
||||
this.$emit('refresh');
|
||||
},
|
||||
scrollUp,
|
||||
scrollDown,
|
||||
handleScrollUp() {
|
||||
this.$emit('scrollUp');
|
||||
},
|
||||
handleScrollDown() {
|
||||
this.$emit('scrollDown');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -50,6 +45,7 @@ export default {
|
|||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-if="scrollUpAvailable"
|
||||
v-gl-tooltip
|
||||
class="controllers-buttons"
|
||||
:title="__('Scroll to top')"
|
||||
|
@ -59,13 +55,15 @@ export default {
|
|||
id="scroll-to-top"
|
||||
class="btn-blank js-scroll-to-top"
|
||||
:aria-label="__('Scroll to top')"
|
||||
:disabled="!scrollToTopEnabled"
|
||||
@click="scrollUp()"
|
||||
:disabled="scrollUpButtonDisabled"
|
||||
@click="handleScrollUp()"
|
||||
><icon name="scroll_up"
|
||||
/></gl-button>
|
||||
</div>
|
||||
<div
|
||||
v-if="scrollDownAvailable"
|
||||
v-gl-tooltip
|
||||
:disabled="scrollUpButtonDisabled"
|
||||
class="controllers-buttons"
|
||||
:title="__('Scroll to bottom')"
|
||||
aria-labelledby="scroll-to-bottom"
|
||||
|
@ -74,8 +72,9 @@ export default {
|
|||
id="scroll-to-bottom"
|
||||
class="btn-blank js-scroll-to-bottom"
|
||||
:aria-label="__('Scroll to bottom')"
|
||||
:disabled="!scrollToBottomEnabled"
|
||||
@click="scrollDown()"
|
||||
:v-if="scrollDownAvailable"
|
||||
:disabled="scrollDownButtonDisabled"
|
||||
@click="handleScrollDown()"
|
||||
><icon name="scroll_down"
|
||||
/></gl-button>
|
||||
</div>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import Api from '~/api';
|
||||
import { backOff } from '~/lib/utils/common_utils';
|
||||
import httpStatusCodes from '~/lib/utils/http_status';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
|
@ -16,9 +15,10 @@ const flashLogsError = () => {
|
|||
flash(s__('Metrics|There was an error fetching the logs, please try again'));
|
||||
};
|
||||
|
||||
const requestLogsUntilData = params =>
|
||||
const requestUntilData = (url, params) =>
|
||||
backOff((next, stop) => {
|
||||
Api.getPodLogs(params)
|
||||
axios
|
||||
.get(url, { params })
|
||||
.then(res => {
|
||||
if (res.status === httpStatusCodes.ACCEPTED) {
|
||||
next();
|
||||
|
@ -31,10 +31,36 @@ const requestLogsUntilData = params =>
|
|||
});
|
||||
});
|
||||
|
||||
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
|
||||
if (timeRange) {
|
||||
commit(types.SET_TIME_RANGE, timeRange);
|
||||
const requestLogsUntilData = state => {
|
||||
const params = {};
|
||||
const { logs_api_path } = state.environments.options.find(
|
||||
({ name }) => name === state.environments.current,
|
||||
);
|
||||
|
||||
if (state.pods.current) {
|
||||
params.pod_name = state.pods.current;
|
||||
}
|
||||
if (state.search) {
|
||||
params.search = state.search;
|
||||
}
|
||||
if (state.timeRange.current) {
|
||||
try {
|
||||
const { start, end } = convertToFixedRange(state.timeRange.current);
|
||||
params.start = start;
|
||||
params.end = end;
|
||||
} catch {
|
||||
flashTimeRangeWarning();
|
||||
}
|
||||
}
|
||||
if (state.logs.cursor) {
|
||||
params.cursor = state.logs.cursor;
|
||||
}
|
||||
|
||||
return requestUntilData(logs_api_path, params);
|
||||
};
|
||||
|
||||
export const setInitData = ({ commit }, { timeRange, environmentName, podName }) => {
|
||||
commit(types.SET_TIME_RANGE, timeRange);
|
||||
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
|
||||
commit(types.SET_CURRENT_POD_NAME, podName);
|
||||
};
|
||||
|
@ -60,10 +86,15 @@ export const showEnvironment = ({ dispatch, commit }, environmentName) => {
|
|||
dispatch('fetchLogs');
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch environments data and initial logs
|
||||
* @param {Object} store
|
||||
* @param {String} environmentsPath
|
||||
*/
|
||||
export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
|
||||
commit(types.REQUEST_ENVIRONMENTS_DATA);
|
||||
|
||||
axios
|
||||
return axios
|
||||
.get(environmentsPath)
|
||||
.then(({ data }) => {
|
||||
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
|
||||
|
@ -76,32 +107,16 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
|
|||
};
|
||||
|
||||
export const fetchLogs = ({ commit, state }) => {
|
||||
const params = {
|
||||
environment: state.environments.options.find(({ name }) => name === state.environments.current),
|
||||
podName: state.pods.current,
|
||||
search: state.search,
|
||||
};
|
||||
|
||||
if (state.timeRange.current) {
|
||||
try {
|
||||
const { start, end } = convertToFixedRange(state.timeRange.current);
|
||||
params.start = start;
|
||||
params.end = end;
|
||||
} catch {
|
||||
flashTimeRangeWarning();
|
||||
}
|
||||
}
|
||||
|
||||
commit(types.REQUEST_PODS_DATA);
|
||||
commit(types.REQUEST_LOGS_DATA);
|
||||
|
||||
return requestLogsUntilData(params)
|
||||
return requestLogsUntilData(state)
|
||||
.then(({ data }) => {
|
||||
const { pod_name, pods, logs } = data;
|
||||
const { pod_name, pods, logs, cursor } = data;
|
||||
commit(types.SET_CURRENT_POD_NAME, pod_name);
|
||||
|
||||
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
|
||||
commit(types.RECEIVE_LOGS_DATA_SUCCESS, logs);
|
||||
commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.RECEIVE_PODS_DATA_ERROR);
|
||||
|
@ -110,5 +125,24 @@ export const fetchLogs = ({ commit, state }) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const fetchMoreLogsPrepend = ({ commit, state }) => {
|
||||
if (state.logs.isComplete) {
|
||||
// return when all logs are loaded
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
commit(types.REQUEST_LOGS_DATA_PREPEND);
|
||||
|
||||
return requestLogsUntilData(state)
|
||||
.then(({ data }) => {
|
||||
const { logs, cursor } = data;
|
||||
commit(types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS, { logs, cursor });
|
||||
})
|
||||
.catch(() => {
|
||||
commit(types.RECEIVE_LOGS_DATA_PREPEND_ERROR);
|
||||
flashLogsError();
|
||||
});
|
||||
};
|
||||
|
||||
// prevent babel-plugin-rewire from generating an invalid default during karma tests
|
||||
export default () => {};
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import dateFormat from 'dateformat';
|
||||
import { formatDate } from '../utils';
|
||||
|
||||
export const trace = state =>
|
||||
state.logs.lines
|
||||
.map(item => [dateFormat(item.timestamp, 'UTC:mmm dd HH:MM:ss.l"Z"'), item.message].join(' | '))
|
||||
.join('\n');
|
||||
const mapTrace = ({ timestamp = null, message = '' }) =>
|
||||
[timestamp ? formatDate(timestamp) : '', message].join(' | ');
|
||||
|
||||
export const trace = state => state.logs.lines.map(mapTrace).join('\n');
|
||||
|
||||
// prevent babel-plugin-rewire from generating an invalid default during karma tests
|
||||
export default () => {};
|
||||
|
|
|
@ -10,6 +10,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_ERROR = 'RECEIVE_ENVIRONMENTS_DATA_ERROR'
|
|||
export const REQUEST_LOGS_DATA = 'REQUEST_LOGS_DATA';
|
||||
export const RECEIVE_LOGS_DATA_SUCCESS = 'RECEIVE_LOGS_DATA_SUCCESS';
|
||||
export const RECEIVE_LOGS_DATA_ERROR = 'RECEIVE_LOGS_DATA_ERROR';
|
||||
export const REQUEST_LOGS_DATA_PREPEND = 'REQUEST_LOGS_DATA_PREPEND';
|
||||
export const RECEIVE_LOGS_DATA_PREPEND_SUCCESS = 'RECEIVE_LOGS_DATA_PREPEND_SUCCESS';
|
||||
export const RECEIVE_LOGS_DATA_PREPEND_ERROR = 'RECEIVE_LOGS_DATA_PREPEND_ERROR';
|
||||
|
||||
export const REQUEST_PODS_DATA = 'REQUEST_PODS_DATA';
|
||||
export const RECEIVE_PODS_DATA_SUCCESS = 'RECEIVE_PODS_DATA_SUCCESS';
|
||||
|
|
|
@ -1,17 +1,24 @@
|
|||
import * as types from './mutation_types';
|
||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
|
||||
const mapLine = ({ timestamp, message }) => ({
|
||||
timestamp,
|
||||
message,
|
||||
});
|
||||
|
||||
export default {
|
||||
/** Search data */
|
||||
// Search Data
|
||||
[types.SET_SEARCH](state, searchQuery) {
|
||||
state.search = searchQuery;
|
||||
},
|
||||
|
||||
/** Time Range data */
|
||||
// Time Range Data
|
||||
[types.SET_TIME_RANGE](state, timeRange) {
|
||||
state.timeRange.current = timeRange;
|
||||
state.timeRange.selected = timeRange;
|
||||
state.timeRange.current = convertToFixedRange(timeRange);
|
||||
},
|
||||
|
||||
/** Environments data */
|
||||
// Environments Data
|
||||
[types.SET_PROJECT_ENVIRONMENT](state, environmentName) {
|
||||
state.environments.current = environmentName;
|
||||
},
|
||||
|
@ -28,24 +35,49 @@ export default {
|
|||
state.environments.isLoading = false;
|
||||
},
|
||||
|
||||
/** Logs data */
|
||||
// Logs data
|
||||
[types.REQUEST_LOGS_DATA](state) {
|
||||
state.timeRange.current = convertToFixedRange(state.timeRange.selected);
|
||||
|
||||
state.logs.lines = [];
|
||||
state.logs.isLoading = true;
|
||||
|
||||
// start pagination from the beginning
|
||||
state.logs.cursor = null;
|
||||
state.logs.isComplete = false;
|
||||
},
|
||||
[types.RECEIVE_LOGS_DATA_SUCCESS](state, lines) {
|
||||
state.logs.lines = lines;
|
||||
[types.RECEIVE_LOGS_DATA_SUCCESS](state, { logs = [], cursor }) {
|
||||
state.logs.lines = logs.map(mapLine);
|
||||
state.logs.isLoading = false;
|
||||
state.logs.isComplete = true;
|
||||
state.logs.cursor = cursor;
|
||||
|
||||
if (!cursor) {
|
||||
state.logs.isComplete = true;
|
||||
}
|
||||
},
|
||||
[types.RECEIVE_LOGS_DATA_ERROR](state) {
|
||||
state.logs.lines = [];
|
||||
state.logs.isLoading = false;
|
||||
state.logs.isComplete = true;
|
||||
},
|
||||
|
||||
/** Pods data */
|
||||
[types.REQUEST_LOGS_DATA_PREPEND](state) {
|
||||
state.logs.isLoading = true;
|
||||
},
|
||||
[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, { logs = [], cursor }) {
|
||||
const lines = logs.map(mapLine);
|
||||
state.logs.lines = lines.concat(state.logs.lines);
|
||||
state.logs.isLoading = false;
|
||||
state.logs.cursor = cursor;
|
||||
|
||||
if (!cursor) {
|
||||
state.logs.isComplete = true;
|
||||
}
|
||||
},
|
||||
[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state) {
|
||||
state.logs.isLoading = false;
|
||||
},
|
||||
|
||||
// Pods data
|
||||
[types.SET_CURRENT_POD_NAME](state, podName) {
|
||||
state.pods.current = podName;
|
||||
},
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { timeRanges, defaultTimeRange } from '~/monitoring/constants';
|
||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||
|
||||
export default () => ({
|
||||
/**
|
||||
|
@ -11,7 +12,10 @@ export default () => ({
|
|||
*/
|
||||
timeRange: {
|
||||
options: timeRanges,
|
||||
current: defaultTimeRange,
|
||||
// Selected time range, can be fixed or relative
|
||||
selected: defaultTimeRange,
|
||||
// Current time range, must be fixed
|
||||
current: convertToFixedRange(defaultTimeRange),
|
||||
},
|
||||
|
||||
/**
|
||||
|
@ -29,7 +33,12 @@ export default () => ({
|
|||
logs: {
|
||||
lines: [],
|
||||
isLoading: false,
|
||||
isComplete: true,
|
||||
/**
|
||||
* Logs `cursor` represents the current pagination position,
|
||||
* Should be sent in next batch (page) of logs to be fetched
|
||||
*/
|
||||
cursor: null,
|
||||
isComplete: false,
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
|
||||
import dateFormat from 'dateformat';
|
||||
|
||||
const dateFormatMask = 'UTC:mmm dd HH:MM:ss.l"Z"';
|
||||
|
||||
/**
|
||||
* Returns a time range (`start`, `end`) where `start` is the
|
||||
|
@ -20,4 +23,6 @@ export const getTimeRange = (seconds = 0) => {
|
|||
};
|
||||
};
|
||||
|
||||
export const formatDate = timestamp => dateFormat(timestamp, dateFormatMask);
|
||||
|
||||
export default {};
|
||||
|
|
1
app/assets/javascripts/pages/admin/sessions/index.js
Normal file
1
app/assets/javascripts/pages/admin/sessions/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
import '~/pages/sessions/index';
|
|
@ -1,11 +1,12 @@
|
|||
<script>
|
||||
import { mapState, mapActions } from 'vuex';
|
||||
import { GlSkeletonLoading, GlEmptyState } from '@gitlab/ui';
|
||||
import { GlSkeletonLoading, GlEmptyState, GlLink } from '@gitlab/ui';
|
||||
import {
|
||||
getParameterByName,
|
||||
historyPushState,
|
||||
buildUrlWithCurrentLocation,
|
||||
} from '~/lib/utils/common_utils';
|
||||
import { __ } from '~/locale';
|
||||
import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue';
|
||||
import ReleaseBlock from './release_block.vue';
|
||||
|
||||
|
@ -16,13 +17,14 @@ export default {
|
|||
GlEmptyState,
|
||||
ReleaseBlock,
|
||||
TablePagination,
|
||||
GlLink,
|
||||
},
|
||||
props: {
|
||||
projectId: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
documentationLink: {
|
||||
documentationPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
|
@ -30,6 +32,11 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
newReleasePath: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapState('list', ['isLoading', 'releases', 'hasError', 'pageInfo']),
|
||||
|
@ -39,6 +46,11 @@ export default {
|
|||
shouldRenderSuccessState() {
|
||||
return this.releases.length && !this.isLoading && !this.hasError;
|
||||
},
|
||||
emptyStateText() {
|
||||
return __(
|
||||
"Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.",
|
||||
);
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchReleases({
|
||||
|
@ -56,7 +68,16 @@ export default {
|
|||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="prepend-top-default">
|
||||
<div class="flex flex-column mt-2">
|
||||
<gl-link
|
||||
v-if="newReleasePath"
|
||||
:href="newReleasePath"
|
||||
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
|
||||
class="btn btn-success align-self-end mb-2 js-new-release-btn"
|
||||
>
|
||||
{{ __('New release') }}
|
||||
</gl-link>
|
||||
|
||||
<gl-skeleton-loading v-if="isLoading" class="js-loading" />
|
||||
|
||||
<gl-empty-state
|
||||
|
@ -64,14 +85,20 @@ export default {
|
|||
class="js-empty-state"
|
||||
:title="__('Getting started with releases')"
|
||||
:svg-path="illustrationPath"
|
||||
:description="
|
||||
__(
|
||||
'Releases are based on Git tags and mark specific points in a project\'s development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.',
|
||||
)
|
||||
"
|
||||
:primary-button-link="documentationLink"
|
||||
:primary-button-text="__('Open Documentation')"
|
||||
/>
|
||||
>
|
||||
<template #description>
|
||||
<span id="releases-description">
|
||||
{{ emptyStateText }}
|
||||
<gl-link
|
||||
:href="documentationPath"
|
||||
:aria-label="__('Releases documentation')"
|
||||
target="_blank"
|
||||
>
|
||||
{{ __('More information') }}
|
||||
</gl-link>
|
||||
</span>
|
||||
</template>
|
||||
</gl-empty-state>
|
||||
|
||||
<div v-else-if="shouldRenderSuccessState" class="js-success-state">
|
||||
<release-block
|
||||
|
|
|
@ -15,11 +15,7 @@ export default () => {
|
|||
}),
|
||||
render: h =>
|
||||
h(ReleaseListApp, {
|
||||
props: {
|
||||
projectId: el.dataset.projectId,
|
||||
documentationLink: el.dataset.documentationPath,
|
||||
illustrationPath: el.dataset.illustrationPath,
|
||||
},
|
||||
props: el.dataset,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { GlTooltipDirective } from '@gitlab/ui';
|
||||
import Icon from '~/vue_shared/components/icon.vue';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import { __ } from '~/locale';
|
||||
import { getCommitIconMap } from '~/ide/utils';
|
||||
|
||||
export default {
|
||||
|
@ -51,17 +51,7 @@ export default {
|
|||
tooltipTitle() {
|
||||
if (!this.showTooltip || !this.file.changed) return undefined;
|
||||
|
||||
const type = this.file.tempFile ? 'addition' : 'modification';
|
||||
|
||||
if (this.file.staged) {
|
||||
return sprintf(__('Staged %{type}'), {
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
return sprintf(__('Unstaged %{type}'), {
|
||||
type,
|
||||
});
|
||||
return this.file.tempFile ? __('Added') : __('Modified');
|
||||
},
|
||||
showIcon() {
|
||||
return (
|
||||
|
|
|
@ -257,7 +257,6 @@
|
|||
width: 15px;
|
||||
height: 15px;
|
||||
display: $svg-display;
|
||||
fill: $gl-text-color;
|
||||
top: $svg-top;
|
||||
}
|
||||
|
||||
|
|
|
@ -358,17 +358,30 @@
|
|||
}
|
||||
}
|
||||
|
||||
.build-page-pod-logs {
|
||||
.environment-logs-viewer {
|
||||
.build-trace-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.log-lines,
|
||||
.gl-infinite-scroll-container {
|
||||
// makes scrollbar visible by creating contrast
|
||||
background: $black;
|
||||
}
|
||||
|
||||
.gl-infinite-scroll-legend {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.build-trace {
|
||||
@include build-trace();
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
@include build-trace-top-bar($gl-line-height * 5);
|
||||
position: relative;
|
||||
top: 0;
|
||||
|
||||
.dropdown-menu-toggle {
|
||||
width: 200px;
|
||||
|
@ -395,4 +408,9 @@
|
|||
.build-loader-animation {
|
||||
@include build-loader-animation;
|
||||
}
|
||||
|
||||
.log-footer {
|
||||
color: $white-normal;
|
||||
background-color: $gray-900;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,84 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Authenticates2FAForAdminMode
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
include AuthenticatesWithTwoFactor
|
||||
end
|
||||
|
||||
def admin_mode_prompt_for_two_factor(user)
|
||||
return handle_locked_user(user) unless user.can?(:log_in)
|
||||
|
||||
session[:otp_user_id] = user.id
|
||||
setup_u2f_authentication(user)
|
||||
|
||||
render 'admin/sessions/two_factor', layout: 'application'
|
||||
end
|
||||
|
||||
def admin_mode_authenticate_with_two_factor
|
||||
user = current_user
|
||||
|
||||
return handle_locked_user(user) unless user.can?(:log_in)
|
||||
|
||||
if user_params[:otp_attempt].present? && session[:otp_user_id]
|
||||
admin_mode_authenticate_with_two_factor_via_otp(user)
|
||||
elsif user_params[:device_response].present? && session[:otp_user_id]
|
||||
admin_mode_authenticate_with_two_factor_via_u2f(user)
|
||||
elsif user && user.valid_password?(user_params[:password])
|
||||
admin_mode_prompt_for_two_factor(user)
|
||||
else
|
||||
invalid_login_redirect
|
||||
end
|
||||
end
|
||||
|
||||
def admin_mode_authenticate_with_two_factor_via_otp(user)
|
||||
if valid_otp_attempt?(user)
|
||||
# Remove any lingering user data from login
|
||||
session.delete(:otp_user_id)
|
||||
|
||||
user.save!
|
||||
|
||||
# The admin user has successfully passed 2fa, enable admin mode ignoring password
|
||||
enable_admin_mode
|
||||
else
|
||||
user.increment_failed_attempts!
|
||||
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=OTP")
|
||||
flash.now[:alert] = _('Invalid two-factor code.')
|
||||
|
||||
admin_mode_prompt_for_two_factor(user)
|
||||
end
|
||||
end
|
||||
|
||||
def admin_mode_authenticate_with_two_factor_via_u2f(user)
|
||||
if U2fRegistration.authenticate(user, u2f_app_id, user_params[:device_response], session[:challenge])
|
||||
# Remove any lingering user data from login
|
||||
session.delete(:otp_user_id)
|
||||
session.delete(:challenge)
|
||||
|
||||
# The admin user has successfully passed 2fa, enable admin mode ignoring password
|
||||
enable_admin_mode
|
||||
else
|
||||
user.increment_failed_attempts!
|
||||
Gitlab::AppLogger.info("Failed Admin Mode Login: user=#{user.username} ip=#{request.remote_ip} method=U2F")
|
||||
flash.now[:alert] = _('Authentication via U2F device failed.')
|
||||
|
||||
admin_mode_prompt_for_two_factor(user)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enable_admin_mode
|
||||
if current_user_mode.enable_admin_mode!(skip_password_validation: true)
|
||||
redirect_to redirect_path, notice: _('Admin mode enabled')
|
||||
else
|
||||
invalid_login_redirect
|
||||
end
|
||||
end
|
||||
|
||||
def invalid_login_redirect
|
||||
flash.now[:alert] = _('Invalid login or password')
|
||||
render :new
|
||||
end
|
||||
end
|
|
@ -1,6 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Admin::SessionsController < ApplicationController
|
||||
include Authenticates2FAForAdminMode
|
||||
include InternalRedirect
|
||||
|
||||
before_action :user_is_admin!
|
||||
|
@ -15,7 +16,9 @@ class Admin::SessionsController < ApplicationController
|
|||
end
|
||||
|
||||
def create
|
||||
if current_user_mode.enable_admin_mode!(password: params[:password])
|
||||
if two_factor_enabled_for_user?
|
||||
admin_mode_authenticate_with_two_factor
|
||||
elsif current_user_mode.enable_admin_mode!(password: user_params[:password])
|
||||
redirect_to redirect_path, notice: _('Admin mode enabled')
|
||||
else
|
||||
flash.now[:alert] = _('Invalid login or password')
|
||||
|
@ -37,6 +40,10 @@ class Admin::SessionsController < ApplicationController
|
|||
render_404 unless current_user&.admin?
|
||||
end
|
||||
|
||||
def two_factor_enabled_for_user?
|
||||
current_user&.two_factor_enabled?
|
||||
end
|
||||
|
||||
def redirect_path
|
||||
redirect_to_path = safe_redirect_path(stored_location_for(:redirect)) || safe_redirect_path_for_url(request.referer)
|
||||
|
||||
|
@ -51,4 +58,13 @@ class Admin::SessionsController < ApplicationController
|
|||
def excluded_redirect_paths
|
||||
[new_admin_session_path, admin_session_path]
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.fetch(:user, {}).permit(:password, :otp_attempt, :device_response)
|
||||
end
|
||||
|
||||
def valid_otp_attempt?(user)
|
||||
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
|
||||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,8 +3,6 @@
|
|||
# == AuthenticatesWithTwoFactor
|
||||
#
|
||||
# Controller concern to handle two-factor authentication
|
||||
#
|
||||
# Upon inclusion, skips `require_no_authentication` on `:create`.
|
||||
module AuthenticatesWithTwoFactor
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||
include AuthenticatesWithTwoFactor
|
||||
include Authenticates2FAForAdminMode
|
||||
include Devise::Controllers::Rememberable
|
||||
include AuthHelper
|
||||
include InitializesCurrentUserMode
|
||||
|
@ -97,7 +98,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
log_audit_event(current_user, with: oauth['provider'])
|
||||
|
||||
if Feature.enabled?(:user_mode_in_session)
|
||||
return admin_mode_flow if current_user_mode.admin_mode_requested?
|
||||
return admin_mode_flow(auth_module::User) if current_user_mode.admin_mode_requested?
|
||||
end
|
||||
|
||||
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth, session)
|
||||
|
@ -245,13 +246,19 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
|||
end
|
||||
end
|
||||
|
||||
def admin_mode_flow
|
||||
if omniauth_identity_matches_current_user?
|
||||
def admin_mode_flow(auth_user_class)
|
||||
auth_user = build_auth_user(auth_user_class)
|
||||
|
||||
return fail_admin_mode_invalid_credentials unless omniauth_identity_matches_current_user?
|
||||
|
||||
if current_user.two_factor_enabled? && !auth_user.bypass_two_factor?
|
||||
admin_mode_prompt_for_two_factor(current_user)
|
||||
else
|
||||
# Can only reach here if the omniauth identity matches current user
|
||||
# and current_user is an admin that requested admin mode
|
||||
current_user_mode.enable_admin_mode!(skip_password_validation: true)
|
||||
|
||||
redirect_to stored_location_for(:redirect) || admin_root_path, notice: _('Admin mode enabled')
|
||||
else
|
||||
fail_admin_mode_invalid_credentials
|
||||
end
|
||||
end
|
||||
|
||||
|
|
60
app/controllers/projects/import/jira_controller.rb
Normal file
60
app/controllers/projects/import/jira_controller.rb
Normal file
|
@ -0,0 +1,60 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Projects
|
||||
module Import
|
||||
class JiraController < Projects::ApplicationController
|
||||
before_action :jira_import_enabled?
|
||||
before_action :jira_integration_configured?
|
||||
|
||||
def show
|
||||
unless @project.import_state&.in_progress?
|
||||
jira_client = @project.jira_service.client
|
||||
@jira_projects = jira_client.Project.all.map { |p| ["#{p.name} (#{p.key})", p.key] }
|
||||
end
|
||||
|
||||
flash[:notice] = _("Import %{status}") % { status: @project.import_state.status } if @project.import_state.present? && !@project.import_state.none?
|
||||
end
|
||||
|
||||
def import
|
||||
import_state = @project.import_state || @project.create_import_state
|
||||
|
||||
schedule_import(jira_import_params) unless import_state.in_progress?
|
||||
|
||||
redirect_to project_import_jira_path(@project)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def jira_import_enabled?
|
||||
return if Feature.enabled?(:jira_issue_import, @project)
|
||||
|
||||
redirect_to project_issues_path(@project)
|
||||
end
|
||||
|
||||
def jira_integration_configured?
|
||||
return if @project.jira_service
|
||||
|
||||
flash[:notice] = _("Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page." %
|
||||
{ strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe })
|
||||
redirect_to project_issues_path(@project)
|
||||
end
|
||||
|
||||
def schedule_import(params)
|
||||
import_data = @project.create_or_update_import_data(data: {}).becomes(JiraImportData)
|
||||
|
||||
import_data << JiraImportData::JiraProjectDetails.new(
|
||||
params[:jira_project_key],
|
||||
Time.now.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
{ user_id: current_user.id, name: current_user.name }
|
||||
)
|
||||
|
||||
@project.import_type = 'jira'
|
||||
@project.import_state.schedule if @project.save
|
||||
end
|
||||
|
||||
def jira_import_params
|
||||
params.permit(:jira_project_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -9,7 +9,7 @@ module Mutations
|
|||
end
|
||||
|
||||
def group_resolver
|
||||
Resolvers::GroupResolver.new(object: nil, context: context)
|
||||
Resolvers::GroupResolver.new(object: nil, context: context, field: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -14,7 +14,7 @@ module Mutations
|
|||
def issuable_resolver(type, parent, context)
|
||||
resolver_class = "Resolvers::#{type.to_s.classify.pluralize}Resolver".constantize
|
||||
|
||||
resolver_class.single.new(object: parent, context: context)
|
||||
resolver_class.single.new(object: parent, context: context, field: nil)
|
||||
end
|
||||
|
||||
def resolve_issuable_parent(parent_path)
|
||||
|
|
|
@ -9,7 +9,7 @@ module Mutations
|
|||
end
|
||||
|
||||
def project_resolver
|
||||
Resolvers::ProjectResolver.new(object: nil, context: context)
|
||||
Resolvers::ProjectResolver.new(object: nil, context: context, field: nil)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,7 +17,9 @@ module ReleasesHelper
|
|||
project_id: @project.id,
|
||||
illustration_path: illustration,
|
||||
documentation_path: help_page
|
||||
}
|
||||
}.tap do |data|
|
||||
data[:new_release_path] = new_project_tag_path(@project) if can?(current_user, :create_release, @project)
|
||||
end
|
||||
end
|
||||
|
||||
def data_for_edit_release_page
|
||||
|
|
|
@ -100,7 +100,13 @@ module BulkInsertSafe
|
|||
def _bulk_insert_item_attributes(items, validate_items)
|
||||
items.map do |item|
|
||||
item.validate! if validate_items
|
||||
attributes = item.attributes
|
||||
|
||||
attributes = {}
|
||||
column_names.each do |name|
|
||||
value = item.read_attribute(name)
|
||||
value = item.type_for_attribute(name).serialize(value) # rubocop:disable Cop/ActiveRecordSerialize
|
||||
attributes[name] = value
|
||||
end
|
||||
|
||||
_bulk_insert_reject_primary_key!(attributes, item.class.primary_key)
|
||||
|
||||
|
|
19
app/models/jira_import_data.rb
Normal file
19
app/models/jira_import_data.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class JiraImportData < ProjectImportData
|
||||
JiraProjectDetails = Struct.new(:key, :scheduled_at, :scheduled_by)
|
||||
|
||||
def projects
|
||||
return [] unless data
|
||||
|
||||
projects = data.dig('jira', 'projects').map do |p|
|
||||
JiraProjectDetails.new(p['key'], p['scheduled_at'], p['scheduled_by'])
|
||||
end
|
||||
projects.sort_by { |jp| jp.scheduled_at }
|
||||
end
|
||||
|
||||
def <<(project)
|
||||
self.data ||= { jira: { projects: [] } }
|
||||
self.data['jira']['projects'] << project.to_h.deep_stringify_keys!
|
||||
end
|
||||
end
|
|
@ -1,7 +1,7 @@
|
|||
= form_tag(admin_session_path, method: :post, html: { class: 'new_user gl-show-field-errors', 'aria-live': 'assertive'}) do
|
||||
.form-group
|
||||
= label_tag :password, _('Password'), class: 'label-bold'
|
||||
= password_field_tag :password, nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
|
||||
= label_tag :user_password, _('Password'), class: 'label-bold'
|
||||
= password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
|
||||
|
||||
.submit-container.move-submit-down
|
||||
= submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
%ul.nav-links.new-session-tabs.nav-tabs.nav{ role: 'tablist' }
|
||||
%li.nav-item{ role: 'presentation' }
|
||||
%a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= _('Enter Admin Mode')
|
||||
%a.nav-link.active{ href: '#login-pane', data: { toggle: 'tab', qa_selector: 'sign_in_tab' }, role: 'tab' }= tab_title
|
||||
|
|
9
app/views/admin/sessions/_two_factor_otp.html.haml
Normal file
9
app/views/admin/sessions/_two_factor_otp.html.haml
Normal file
|
@ -0,0 +1,9 @@
|
|||
= form_tag(admin_session_path, { method: :post, class: "edit_user gl-show-field-errors js-2fa-form #{'hidden' if current_user.two_factor_u2f_enabled?}" }) do
|
||||
.form-group
|
||||
= label_tag :user_otp_attempt, _('Two-Factor Authentication code')
|
||||
= text_field_tag 'user[otp_attempt]', nil, class: 'form-control', required: true, autofocus: true, autocomplete: 'off', title: _('This field is required.')
|
||||
%p.form-text.text-muted.hint
|
||||
= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
|
||||
|
||||
.submit-container.move-submit-down
|
||||
= submit_tag 'Verify code', class: 'btn btn-success'
|
17
app/views/admin/sessions/_two_factor_u2f.html.haml
Normal file
17
app/views/admin/sessions/_two_factor_u2f.html.haml
Normal file
|
@ -0,0 +1,17 @@
|
|||
#js-authenticate-u2f
|
||||
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
|
||||
|
||||
%script#js-authenticate-u2f-in-progress{ type: "text/template" }
|
||||
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
|
||||
|
||||
-# haml-lint:disable NoPlainNodes
|
||||
%script#js-authenticate-u2f-error{ type: "text/template" }
|
||||
%div
|
||||
%p <%= error_message %> (#{_("error code:")} <%= error_code %>)
|
||||
%a.btn.btn-block.btn-warning#js-u2f-try-again= _("Try again?")
|
||||
|
||||
%script#js-authenticate-u2f-authenticated{ type: "text/template" }
|
||||
%div
|
||||
%p= _("We heard back from your U2F device. You have been authenticated.")
|
||||
= form_tag(admin_session_path, method: :post, id: 'js-login-u2f-form') do |f|
|
||||
= hidden_field_tag 'user[device_response]', nil, class: 'form-control', required: true, id: "js-device-response"
|
|
@ -2,10 +2,10 @@
|
|||
- page_title _('Enter Admin Mode')
|
||||
|
||||
.row.justify-content-center
|
||||
.col-6.new-session-forms-container
|
||||
.col-md-5.new-session-forms-container
|
||||
.login-page
|
||||
#signin-container
|
||||
= render 'admin/sessions/tabs_normal'
|
||||
= render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
|
||||
.tab-content
|
||||
- if !current_user.require_password_creation_for_web?
|
||||
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
|
||||
|
@ -14,7 +14,7 @@
|
|||
|
||||
- if omniauth_enabled? && button_based_providers_enabled?
|
||||
.clearfix
|
||||
= render 'devise/shared/omniauth_box'
|
||||
= render 'devise/shared/omniauth_box', hide_remember_me: true
|
||||
|
||||
-# Show a message if none of the mechanisms above are enabled
|
||||
- if current_user.require_password_creation_for_web? && !omniauth_enabled?
|
||||
|
|
15
app/views/admin/sessions/two_factor.html.haml
Normal file
15
app/views/admin/sessions/two_factor.html.haml
Normal file
|
@ -0,0 +1,15 @@
|
|||
- @hide_breadcrumbs = true
|
||||
- page_title _('Enter 2FA for Admin Mode')
|
||||
|
||||
.row.justify-content-center
|
||||
.col-md-5.new-session-forms-container
|
||||
.login-page
|
||||
#signin-container
|
||||
= render 'admin/sessions/tabs_normal', tab_title: _('Enter Admin Mode')
|
||||
.tab-content
|
||||
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
|
||||
.login-body
|
||||
- if current_user.two_factor_otp_enabled?
|
||||
= render 'admin/sessions/two_factor_otp'
|
||||
- if current_user.two_factor_u2f_enabled?
|
||||
= render 'admin/sessions/two_factor_u2f'
|
|
@ -10,8 +10,9 @@
|
|||
= provider_image_tag(provider)
|
||||
%span
|
||||
= label_for_provider(provider)
|
||||
%fieldset.remember-me
|
||||
%label
|
||||
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
|
||||
%span
|
||||
Remember me
|
||||
- unless defined?(hide_remember_me) && hide_remember_me
|
||||
%fieldset.remember-me
|
||||
%label
|
||||
= check_box_tag :remember_me, nil, false, class: 'remember-me-checkbox'
|
||||
%span
|
||||
Remember me
|
||||
|
|
24
app/views/projects/import/jira/show.html.haml
Normal file
24
app/views/projects/import/jira/show.html.haml
Normal file
|
@ -0,0 +1,24 @@
|
|||
- title = _('Jira Issue Import')
|
||||
- page_title title
|
||||
- breadcrumb_title title
|
||||
- header_title _("Projects"), root_path
|
||||
|
||||
= render 'import/shared/errors'
|
||||
|
||||
- if @project.import_state&.in_progress?
|
||||
%h3.page-title.d-flex.align-items-center
|
||||
= sprite_icon('issues', size: 16, css_class: 'mr-1')
|
||||
= _('Import in progress')
|
||||
- else
|
||||
%h3.page-title.d-flex.align-items-center
|
||||
= sprite_icon('issues', size: 16, css_class: 'mr-1')
|
||||
= _('Import issues from Jira')
|
||||
|
||||
= form_tag import_project_import_jira_path(@project), method: :post do
|
||||
.form-group.row
|
||||
= label_tag :jira_project_key, _('From project'), class: 'col-form-label col-md-2'
|
||||
.col-md-4
|
||||
= select_tag :jira_project_key, options_for_select(@jira_projects, ''), { class: 'select2' }
|
||||
.form-actions
|
||||
= submit_tag _('Import issues'), class: 'btn btn-success'
|
||||
= link_to _('Cancel'), project_issues_path(@project), class: 'btn btn-cancel'
|
|
@ -7,3 +7,5 @@
|
|||
- else
|
||||
= _('Import CSV')
|
||||
|
||||
- if Feature.enabled?(:jira_issue_import, @project)
|
||||
= link_to _("Import Jira issues"), project_import_jira_path(@project), class: "btn btn-default"
|
||||
|
|
|
@ -36,11 +36,19 @@
|
|||
.form-group.row
|
||||
= label_tag :release_description, s_('TagsPage|Release notes'), class: 'col-form-label col-sm-2'
|
||||
.col-sm-10
|
||||
.form-text.mb-3
|
||||
- link_start = '<a href="%{url}" rel="noopener noreferrer" target="_blank">'.html_safe
|
||||
- releases_page_path = project_releases_path(@project)
|
||||
- releases_page_link_start = link_start % { url: releases_page_path }
|
||||
- docs_url = help_page_path('user/project/releases/index.md', anchor: 'creating-a-release')
|
||||
- docs_link_start = link_start % { url: docs_url }
|
||||
- link_end = '</a>'.html_safe
|
||||
- replacements = { releases_page_link_start: releases_page_link_start, docs_link_start: docs_link_start, link_end: link_end }
|
||||
= s_('TagsPage|Optionally, create a public Release of your project, based on this tag. Release notes are displayed on the %{releases_page_link_start}Releases%{link_end} page. %{docs_link_start}More information%{link_end}').html_safe % replacements
|
||||
|
||||
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
|
||||
= render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here…'), current_text: @release_description
|
||||
= render 'shared/notes/hints'
|
||||
.form-text.text-muted
|
||||
= s_('TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.')
|
||||
.form-actions
|
||||
= button_tag s_('TagsPage|Create tag'), class: 'btn btn-success'
|
||||
= link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel'
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
#js-authenticate-u2f
|
||||
%a.btn.btn-block.btn-info#js-login-2fa-device{ href: '#' }= _("Sign in via 2FA code")
|
||||
|
||||
-# haml-lint:disable InlineJavaScript
|
||||
%script#js-authenticate-u2f-in-progress{ type: "text/template" }
|
||||
%p= _("Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.")
|
||||
|
||||
|
|
|
@ -856,7 +856,7 @@
|
|||
:urgency: :high
|
||||
:resource_boundary: :unknown
|
||||
:weight: 2
|
||||
:idempotent:
|
||||
:idempotent: true
|
||||
- :name: background_migration
|
||||
:feature_category: :not_owned
|
||||
:has_external_dependencies:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AuthorizedProjectsWorker # rubocop:disable Scalability/IdempotentWorker
|
||||
class AuthorizedProjectsWorker
|
||||
include ApplicationWorker
|
||||
prepend WaitableWorker
|
||||
|
||||
|
@ -8,6 +8,8 @@ class AuthorizedProjectsWorker # rubocop:disable Scalability/IdempotentWorker
|
|||
urgency :high
|
||||
weight 2
|
||||
|
||||
idempotent!
|
||||
|
||||
# This is a workaround for a Ruby 2.3.7 bug. rspec-mocks cannot restore the
|
||||
# visibility of prepended modules. See https://github.com/rspec/rspec-mocks/issues/1231
|
||||
# for more details.
|
||||
|
|
5
changelogs/unreleased/196609-remove-staging.yml
Normal file
5
changelogs/unreleased/196609-remove-staging.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Remove staging from commit workflow in the Web IDE
|
||||
merge_request: 26151
|
||||
author:
|
||||
type: removed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: More logs entries are loaded when logs are scrolled to the top
|
||||
merge_request: 26254
|
||||
author:
|
||||
type: added
|
5
changelogs/unreleased/208890-optimize-notes-counters.yml
Normal file
5
changelogs/unreleased/208890-optimize-notes-counters.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Optimize notes counters in usage data
|
||||
merge_request: 26871
|
||||
author:
|
||||
type: performance
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Backfill LfsObjectsProject records of forks
|
||||
merge_request: 26964
|
||||
author:
|
||||
type: other
|
5
changelogs/unreleased/feat-2fa-for-admin-mode.yml
Normal file
5
changelogs/unreleased/feat-2fa-for-admin-mode.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add 2FA support to admin mode feature
|
||||
merge_request: 22281
|
||||
author: Diego Louzán
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add "New release" button to Releases page
|
||||
merge_request: 24516
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Update Ruby version in official CI templates
|
||||
merge_request: 23585
|
||||
author: Takuya Noguchi
|
||||
type: other
|
|
@ -295,6 +295,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
|
|||
draw :repository_scoped
|
||||
draw :repository
|
||||
draw :wiki
|
||||
|
||||
namespace :import do
|
||||
resource :jira, only: [:show], controller: :jira do
|
||||
post :import
|
||||
end
|
||||
end
|
||||
end
|
||||
# End of the /-/ scope.
|
||||
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddIndexOnAuthorIdAndIdAndCreatedAtToNotes < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_index :notes, [:author_id, :created_at]
|
||||
remove_concurrent_index :notes, [:author_id]
|
||||
end
|
||||
|
||||
def down
|
||||
add_concurrent_index :notes, [:author_id]
|
||||
remove_concurrent_index :notes, [:author_id, :created_at]
|
||||
end
|
||||
end
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ScheduleLinkLfsObjectsProjects < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
MIGRATION = 'LinkLfsObjectsProjects'
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
lfs_objects_projects = Gitlab::BackgroundMigration::LinkLfsObjectsProjects::LfsObjectsProject.linkable
|
||||
|
||||
queue_background_migration_jobs_by_range_at_intervals(
|
||||
lfs_objects_projects,
|
||||
MIGRATION,
|
||||
BackgroundMigrationWorker.minimum_interval,
|
||||
batch_size: BATCH_SIZE
|
||||
)
|
||||
end
|
||||
|
||||
def down
|
||||
# No-op. No need to make this reversible. In case the jobs enqueued runs and
|
||||
# fails at some point, some records will be created. When rescheduled, those
|
||||
# records won't be re-created. It's also hard to track which records to clean
|
||||
# up if ever.
|
||||
end
|
||||
end
|
|
@ -2829,7 +2829,7 @@ ActiveRecord::Schema.define(version: 2020_03_11_165635) do
|
|||
t.boolean "resolved_by_push"
|
||||
t.bigint "review_id"
|
||||
t.boolean "confidential"
|
||||
t.index ["author_id"], name: "index_notes_on_author_id"
|
||||
t.index ["author_id", "created_at"], name: "index_notes_on_author_id_and_created_at"
|
||||
t.index ["commit_id"], name: "index_notes_on_commit_id"
|
||||
t.index ["created_at"], name: "index_notes_on_created_at"
|
||||
t.index ["discussion_id"], name: "index_notes_on_discussion_id"
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -636,6 +636,37 @@ found, we should raise a
|
|||
`Gitlab::Graphql::Errors::ResourceNotAvailable` error. Which will be
|
||||
correctly rendered to the clients.
|
||||
|
||||
## Validating arguments
|
||||
|
||||
For validations of single arguments, use the
|
||||
[`prepare` option](https://github.com/rmosolgo/graphql-ruby/blob/master/guides/fields/arguments.md)
|
||||
as normal.
|
||||
|
||||
Sometimes a mutation or resolver may accept a number of optional
|
||||
arguments, but still want to validate that at least one of the optional
|
||||
arguments were given. In this situation, consider using the `#ready?`
|
||||
method within your mutation or resolver to provide the validation. The
|
||||
`#ready?` method will be called before any work is done within the
|
||||
`#resolve` method.
|
||||
|
||||
Example:
|
||||
|
||||
```ruby
|
||||
def ready?(**args)
|
||||
if args.values_at(:body, :position).compact.blank?
|
||||
raise Gitlab::Graphql::Errors::ArgumentError,
|
||||
'body or position arguments are required'
|
||||
end
|
||||
|
||||
# Always remember to call `#super`
|
||||
super(args)
|
||||
end
|
||||
```
|
||||
|
||||
In the future this may be able to be done using `InputUnions` if
|
||||
[this RFC](https://github.com/graphql/graphql-spec/blob/master/rfcs/InputUnion.md)
|
||||
is merged.
|
||||
|
||||
## GitLab's custom scalars
|
||||
|
||||
### `Types::TimeType`
|
||||
|
|
BIN
doc/user/application_security/img/multi_select_v12_9.png
Normal file
BIN
doc/user/application_security/img/multi_select_v12_9.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 32 KiB |
|
@ -113,6 +113,19 @@ context for a vulnerability as you learn more over time.
|
|||
|
||||
![Dismissed vulnerability comment](img/dismissed_info_v12_3.png)
|
||||
|
||||
#### Dismissing multiple vulnerabilities
|
||||
|
||||
> Introduced in [GitLab Ultimate](https://about.gitlab.com/pricing/) 12.9.
|
||||
|
||||
You can dismiss multiple vulnerabilities at once, providing an optional reason.
|
||||
Selecting the checkboxes on the side of each vulnerability in the list will select that individual vulnerability.
|
||||
Alternatively, you can select all the vulnerabilities in the list by selecting the checkbox in the table header.
|
||||
Deselecting the checkbox in the header will deselect all the vulnerabilities in the list.
|
||||
Once you have selected some vulnerabilities, a menu appears at the top of the table that allows you to select a dismissal reason.
|
||||
Pressing the "Dismiss Selected" button will dismiss all the selected vulnerabilities at once, with the reason you chose.
|
||||
|
||||
![Multiple vulnerability dismissal](img/multi_select_v12_9.png)
|
||||
|
||||
### Creating an issue for a vulnerability
|
||||
|
||||
You can create an issue for a vulnerability by selecting the **Create issue**
|
||||
|
|
|
@ -462,6 +462,11 @@ The chart will deploy 5 Elasticsearch nodes: 2 masters, 2 data and 1 client node
|
|||
with resource requests totalling 0.125 CPU and 4.5GB RAM. Each data node requests 1.5GB of memory,
|
||||
which makes it incompatible with clusters of `f1-micro` and `g1-small` instance types.
|
||||
|
||||
NOTE: **Note:**
|
||||
The Elastic Stack cluster application is intended as a log aggregation solution and is not related to our
|
||||
[Advanced Global Search](../search/advanced_global_search.md) functionality, which uses a separate
|
||||
Elasticsearch cluster.
|
||||
|
||||
### Future apps
|
||||
|
||||
Interested in contributing a new GitLab managed app? Visit the
|
||||
|
|
|
@ -69,6 +69,7 @@ The following table depicts the various user permission levels in a project.
|
|||
| See related issues | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||
| Create confidential issue | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
|
||||
| View confidential issues | (*2*) | ✓ | ✓ | ✓ | ✓ |
|
||||
| View [Releases](project/releases/index.md) | ✓ (*6*) | ✓ | ✓ | ✓ | ✓ |
|
||||
| Assign issues | | ✓ | ✓ | ✓ | ✓ |
|
||||
| Label issues | | ✓ | ✓ | ✓ | ✓ |
|
||||
| Set issue weight | | ✓ | ✓ | ✓ | ✓ |
|
||||
|
@ -83,6 +84,7 @@ The following table depicts the various user permission levels in a project.
|
|||
| See a list of merge requests | | ✓ | ✓ | ✓ | ✓ |
|
||||
| View project statistics | | ✓ | ✓ | ✓ | ✓ |
|
||||
| View Error Tracking list | | ✓ | ✓ | ✓ | ✓ |
|
||||
| Create/edit/delete [Releases](project/releases/index.md)| | | ✓ | ✓ | ✓ |
|
||||
| Pull from [Conan repository](packages/conan_repository/index.md), [Maven repository](packages/maven_repository/index.md), or [NPM registry](packages/npm_registry/index.md) **(PREMIUM)** | | ✓ | ✓ | ✓ | ✓ |
|
||||
| Publish to [Conan repository](packages/conan_repository/index.md), [Maven repository](packages/maven_repository/index.md), or [NPM registry](packages/npm_registry/index.md) **(PREMIUM)** | | | ✓ | ✓ | ✓ |
|
||||
| Upload [Design Management](project/issues/design_management.md) files **(PREMIUM)** | | | ✓ | ✓ | ✓ |
|
||||
|
@ -152,6 +154,7 @@ The following table depicts the various user permission levels in a project.
|
|||
1. If **Public pipelines** is enabled in **Project Settings > CI/CD**.
|
||||
1. Not allowed for Guest, Reporter, Developer, Maintainer, or Owner. See [Protected Branches](./project/protected_branches.md).
|
||||
1. If the [branch is protected](./project/protected_branches.md#using-the-allowed-to-merge-and-allowed-to-push-settings), this depends on the access Developers and Maintainers are given.
|
||||
1. Guest users can access GitLab [**Releases**](project/releases/index.md) for downloading assets but are not allowed to download the source code nor see repository information like tags and commits.
|
||||
|
||||
## Project features permissions
|
||||
|
||||
|
@ -198,17 +201,6 @@ Confidential issues can be accessed by reporters and higher permission levels,
|
|||
as well as by guest users that create a confidential issue. To learn more,
|
||||
read through the documentation on [permissions and access to confidential issues](project/issues/confidential_issues.md#permissions-and-access-to-confidential-issues).
|
||||
|
||||
### Releases permissions
|
||||
|
||||
[Project Releases](project/releases/index.md) can be read by project
|
||||
members with Reporter, Developer, Maintainer, and Owner permissions.
|
||||
Guest users can access Release pages for downloading assets but
|
||||
are not allowed to download the source code nor see repository
|
||||
information such as tags and commits.
|
||||
|
||||
Releases can be created, updated, or deleted via [Releases APIs](../api/releases/index.md)
|
||||
by project Developers, Maintainers, and Owners.
|
||||
|
||||
## Group members permissions
|
||||
|
||||
NOTE: **Note:**
|
||||
|
|
|
@ -46,13 +46,15 @@ Logs can be displayed by clicking on a specific pod from [Deploy Boards](../depl
|
|||
|
||||
### Logs view
|
||||
|
||||
The logs view will contain the last 500 lines for a pod, and has control to filter through:
|
||||
The logs view lets you filter the logs by:
|
||||
|
||||
- Pods.
|
||||
- [From GitLab 12.4](https://gitlab.com/gitlab-org/gitlab/issues/5769), environments.
|
||||
- [From GitLab 12.7](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/21656), [full text search](#full-text-search).
|
||||
- [From GitLab 12.8](https://gitlab.com/gitlab-org/gitlab/issues/197879), dates.
|
||||
|
||||
Loading more than 500 log lines is possible from [GitLab 12.9](https://gitlab.com/gitlab-org/gitlab/-/issues/198050) onwards.
|
||||
|
||||
Support for pods with multiple containers is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/13404).
|
||||
|
||||
Support for historical data is coming [in a future release](https://gitlab.com/gitlab-org/gitlab/issues/196191).
|
||||
|
|
|
@ -77,17 +77,18 @@ Navigate to the **Design Management** page from any issue by clicking the **Desi
|
|||
|
||||
To upload design images, click the **Upload Designs** button and select images to upload.
|
||||
|
||||
Designs with the same filename as an existing uploaded design will create a new version
|
||||
of the design, and will replace the previous version.
|
||||
|
||||
Designs cannot be added if the issue has been moved, or its
|
||||
[discussion is locked](../../discussions/#lock-discussions).
|
||||
|
||||
[Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34353) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9,
|
||||
you can drag and drop designs onto the dedicated dropzone to upload them.
|
||||
|
||||
![Drag and drop design uploads](img/design_drag_and_drop_uploads_v12_9.png)
|
||||
|
||||
Designs with the same filename as an existing uploaded design will create a new version
|
||||
of the design, and will replace the previous version. [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/34353) in [GitLab Premium](https://about.gitlab.com/pricing/) 12.9, dropping a design on an existing uploaded design will also create a new version,
|
||||
provided the filenames are the same.
|
||||
|
||||
Designs cannot be added if the issue has been moved, or its
|
||||
[discussion is locked](../../discussions/#lock-discussions).
|
||||
|
||||
### Skipped designs
|
||||
|
||||
Designs with the same filename as an existing uploaded design _and_ whose content has not changed will be skipped.
|
||||
|
|
|
@ -16,13 +16,6 @@ GitLab's **Releases** are a way to track deliverables in your project. Consider
|
|||
a snapshot in time of the source, build output, artifacts, and other metadata
|
||||
associated with a released version of your code.
|
||||
|
||||
There are several ways to create a Release:
|
||||
|
||||
- In the interface, when you create a new Git tag.
|
||||
- In the interface, by adding a release note to an existing Git tag.
|
||||
- Using the [Releases API](../../../api/releases/index.md): we recommend doing this as one of the last
|
||||
steps in your CI/CD release pipeline.
|
||||
|
||||
## Getting started with Releases
|
||||
|
||||
Start by giving a [description](#release-description) to the Release and
|
||||
|
@ -117,7 +110,7 @@ it takes you to the list of Releases.
|
|||
![Number of Releases](img/releases_count_v12_8.png "Incremental counter of Releases")
|
||||
|
||||
For private projects, the number of Releases is displayed to users with Reporter
|
||||
[permissions](../../permissions.md#releases-permissions) or higher. For public projects,
|
||||
[permissions](../../permissions.md#project-members-permissions) or higher. For public projects,
|
||||
it is displayed to every user regardless of their permission level.
|
||||
|
||||
### Upcoming Releases
|
||||
|
@ -130,6 +123,29 @@ Release tag. Once the `released_at` date and time has passed, the badge is autom
|
|||
|
||||
![An upcoming release](img/upcoming_release_v12_7.png)
|
||||
|
||||
## Creating a Release
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/32812) in GitLab
|
||||
12.9, Releases can be created directly through the GitLab Releases UI.
|
||||
|
||||
NOTE: **Note:**
|
||||
Only users with Developer permissions or higher can create Releases.
|
||||
Read more about [Release permissions](../../../user/permissions.md#project-members-permissions).
|
||||
|
||||
To create a new Release through the GitLab UI:
|
||||
|
||||
1. Navigate to **Project overview > Releases** and click the **New release** button.
|
||||
1. On the **New Tag** page, fill out the tag details.
|
||||
1. Optionally, in the **Release notes** field, enter the Release's description.
|
||||
If you leave this field empty, only a tag will be created.
|
||||
If you populate it, both a tag and a Release will be created.
|
||||
1. Click **Create tag**.
|
||||
|
||||
If you created a release, you can view it at **Project overview > Releases**.
|
||||
|
||||
You can also create a Release using the [Releases API](../../../api/releases/index.md#create-a-release):
|
||||
we recommend doing this as one of the last steps in your CI/CD release pipeline.
|
||||
|
||||
## Editing a release
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/26016) in GitLab 12.6.
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 192 KiB |
BIN
doc/user/project/web_ide/img/commit_changes_v12_9.png
Normal file
BIN
doc/user/project/web_ide/img/commit_changes_v12_9.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 665 KiB |
|
@ -43,30 +43,33 @@ you can find a more complete list of supported languages in the
|
|||
NOTE: **Note:**
|
||||
Single file editing is based on the [Ace Editor](https://ace.c9.io).
|
||||
|
||||
## Stage and commit changes
|
||||
## Commit changes
|
||||
|
||||
After making your changes, click the **Commit** button in the bottom left to
|
||||
review the list of changed files. If you're using GitLab 12.6 or older versions,
|
||||
click on each file to review the changes and tick the item to stage a file.
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/4539) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.4 and [brought to GitLab Core](https://gitlab.com/gitlab-org/gitlab-foss/issues/44157) in 10.7.
|
||||
> - From [GitLab 12.7 onwards](https://gitlab.com/gitlab-org/gitlab/issues/33441),
|
||||
files were automatically staged.
|
||||
> - From [GitLab 12.9 onwards](https://gitlab.com/gitlab-org/gitlab/-/issues/196609), support for staging files was removed
|
||||
to prevent loss of unstaged data. All your current changes necessarily have to be
|
||||
committed or discarded.
|
||||
|
||||
From [GitLab 12.7 onward](https://gitlab.com/gitlab-org/gitlab/issues/33441),
|
||||
all your files will be automatically staged. You still have the option to unstage
|
||||
changes in case you want to submit them in multiple smaller commits. To unstage
|
||||
a change, simply click the **Unstage** button when a staged file is open, or click
|
||||
the undo icon next to **Staged changes** to unstage all changes.
|
||||
After making your changes, click the **Commit** button on the bottom-left to
|
||||
review the list of changed files.
|
||||
|
||||
Once you have finalized your changes, you can add a commit message, commit the
|
||||
staged changes and directly create a merge request. In case you don't have write
|
||||
changes and directly create a merge request. In case you don't have write
|
||||
access to the selected branch, you will see a warning, but still be able to create
|
||||
a new branch and start a merge request.
|
||||
|
||||
![Commit changes](img/commit_changes_v12_3.png)
|
||||
To discard a change in a particular file, click the **Discard changes** button on that
|
||||
file in the changes tab. To discard all the changes, click the trash icon on the
|
||||
top-right corner of the changes sidebar.
|
||||
|
||||
![Commit changes](img/commit_changes_v12_9.png)
|
||||
|
||||
## Reviewing changes
|
||||
|
||||
Before you commit your changes, you can compare them with the previous commit
|
||||
by switching to the review mode or selecting the file from the staged files
|
||||
list.
|
||||
by switching to the review mode or selecting the file from the list of changes.
|
||||
|
||||
An additional review mode is available when you open a merge request, which
|
||||
shows you a preview of the merge request diff if you commit your changes.
|
||||
|
|
82
lib/gitlab/background_migration/link_lfs_objects_projects.rb
Normal file
82
lib/gitlab/background_migration/link_lfs_objects_projects.rb
Normal file
|
@ -0,0 +1,82 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module BackgroundMigration
|
||||
# Create missing LfsObjectsProject records for forks
|
||||
class LinkLfsObjectsProjects
|
||||
# Model specifically used for migration.
|
||||
class LfsObjectsProject < ActiveRecord::Base
|
||||
include EachBatch
|
||||
|
||||
self.table_name = 'lfs_objects_projects'
|
||||
|
||||
def self.linkable
|
||||
where(
|
||||
<<~SQL
|
||||
lfs_objects_projects.project_id IN (
|
||||
SELECT fork_network_members.forked_from_project_id
|
||||
FROM fork_network_members
|
||||
WHERE fork_network_members.forked_from_project_id IS NOT NULL
|
||||
)
|
||||
SQL
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Model specifically used for migration.
|
||||
class ForkNetworkMember < ActiveRecord::Base
|
||||
include EachBatch
|
||||
|
||||
self.table_name = 'fork_network_members'
|
||||
|
||||
def self.without_lfs_object(lfs_object_id)
|
||||
where(
|
||||
<<~SQL
|
||||
fork_network_members.project_id NOT IN (
|
||||
SELECT lop.project_id
|
||||
FROM lfs_objects_projects lop
|
||||
WHERE lop.lfs_object_id = #{lfs_object_id}
|
||||
)
|
||||
SQL
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
BATCH_SIZE = 1000
|
||||
|
||||
def perform(start_id, end_id)
|
||||
lfs_objects_projects =
|
||||
Gitlab::BackgroundMigration::LinkLfsObjectsProjects::LfsObjectsProject
|
||||
.linkable
|
||||
.where(id: start_id..end_id)
|
||||
|
||||
return if lfs_objects_projects.empty?
|
||||
|
||||
lfs_objects_projects.find_each do |lop|
|
||||
ForkNetworkMember
|
||||
.select("#{lop.lfs_object_id}, fork_network_members.project_id, NOW(), NOW()")
|
||||
.without_lfs_object(lop.lfs_object_id)
|
||||
.where(forked_from_project_id: lop.project_id)
|
||||
.each_batch(of: BATCH_SIZE) do |batch, index|
|
||||
execute <<~SQL
|
||||
INSERT INTO lfs_objects_projects (lfs_object_id, project_id, created_at, updated_at)
|
||||
#{batch.to_sql}
|
||||
SQL
|
||||
|
||||
logger.info(message: "LinkLfsObjectsProjects: created missing LfsObjectsProject records for LfsObject #{lop.lfs_object_id}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def execute(sql)
|
||||
::ActiveRecord::Base.connection.execute(sql)
|
||||
end
|
||||
|
||||
def logger
|
||||
@logger ||= Gitlab::BackgroundMigration::Logger.build
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,12 +1,13 @@
|
|||
# Template project: https://gitlab.com/pages/jekyll
|
||||
# Docs: https://docs.gitlab.com/ce/pages/
|
||||
image: ruby:2.3
|
||||
image: ruby:2.6
|
||||
|
||||
variables:
|
||||
JEKYLL_ENV: production
|
||||
LC_ALL: C.UTF-8
|
||||
|
||||
before_script:
|
||||
- gem install bundler
|
||||
- bundle install
|
||||
|
||||
test:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# Full project: https://gitlab.com/pages/middleman
|
||||
image: ruby:2.3
|
||||
image: ruby:2.6
|
||||
|
||||
cache:
|
||||
paths:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# Full project: https://gitlab.com/pages/nanoc
|
||||
image: ruby:2.3
|
||||
image: ruby:2.6
|
||||
|
||||
pages:
|
||||
script:
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
# Full project: https://gitlab.com/pages/octopress
|
||||
image: ruby:2.3
|
||||
image: ruby:2.6
|
||||
|
||||
pages:
|
||||
script:
|
||||
|
|
|
@ -68,6 +68,11 @@ msgstr ""
|
|||
msgid "\"%{path}\" did not exist on \"%{ref}\""
|
||||
msgstr ""
|
||||
|
||||
msgid "%d changed file"
|
||||
msgid_plural "%d changed files"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "%d code quality issue"
|
||||
msgid_plural "%d code quality issues"
|
||||
msgstr[0] ""
|
||||
|
@ -186,21 +191,11 @@ msgid_plural "%d shards selected"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "%d staged change"
|
||||
msgid_plural "%d staged changes"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "%d tag"
|
||||
msgid_plural "%d tags"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "%d unstaged change"
|
||||
msgid_plural "%d unstaged changes"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
msgid "%d vulnerability dismissed"
|
||||
msgid_plural "%d vulnerabilities dismissed"
|
||||
msgstr[0] ""
|
||||
|
@ -431,9 +426,6 @@ msgstr ""
|
|||
msgid "%{spanStart}in%{spanEnd} %{errorFn}"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{staged} staged and %{unstaged} unstaged changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "%{start} to %{end}"
|
||||
msgstr ""
|
||||
|
||||
|
@ -777,9 +769,6 @@ msgstr ""
|
|||
msgid "<strong>%{group_name}</strong> group members"
|
||||
msgstr ""
|
||||
|
||||
msgid "<strong>%{stagedFilesLength} staged</strong> and <strong>%{changedFilesLength} unstaged</strong> changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "<strong>Deletes</strong> source branch"
|
||||
msgstr ""
|
||||
|
||||
|
@ -5154,6 +5143,9 @@ msgstr ""
|
|||
msgid "Configure the %{link} integration."
|
||||
msgstr ""
|
||||
|
||||
msgid "Configure the Jira integration first on your project's %{strong_start} Settings > Integrations > Jira%{strong_end} page."
|
||||
msgstr ""
|
||||
|
||||
msgid "Configure the way a user creates a new account."
|
||||
msgstr ""
|
||||
|
||||
|
@ -6878,13 +6870,10 @@ msgstr ""
|
|||
msgid "Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them."
|
||||
msgstr ""
|
||||
|
||||
msgid "Discard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Discard all changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Discard all unstaged changes?"
|
||||
msgid "Discard all changes?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Discard changes"
|
||||
|
@ -7492,6 +7481,9 @@ msgstr ""
|
|||
msgid "Ensure your %{linkStart}environment is part of the deploy stage%{linkEnd} of your CI pipeline to track deployments to your cluster."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter 2FA for Admin Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Admin Mode"
|
||||
msgstr ""
|
||||
|
||||
|
@ -7531,6 +7523,9 @@ msgstr ""
|
|||
msgid "Enter one or more user ID separated by commas"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter the issue description"
|
||||
msgstr ""
|
||||
|
||||
|
@ -7645,6 +7640,12 @@ msgstr ""
|
|||
msgid "Environments|Commit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Currently showing %{fetched} results."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Currently showing all results."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Deploy to..."
|
||||
msgstr ""
|
||||
|
||||
|
@ -7678,6 +7679,9 @@ msgstr ""
|
|||
msgid "Environments|Logs from"
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|Logs from %{start} to %{end}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Environments|New environment"
|
||||
msgstr ""
|
||||
|
||||
|
@ -8962,6 +8966,9 @@ msgstr ""
|
|||
msgid "From merge request merge until deploy to production"
|
||||
msgstr ""
|
||||
|
||||
msgid "From project"
|
||||
msgstr ""
|
||||
|
||||
msgid "From the Kubernetes cluster details view, install Runner from the applications list"
|
||||
msgstr ""
|
||||
|
||||
|
@ -10608,9 +10615,15 @@ msgstr ""
|
|||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import %{status}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import CSV"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import Jira issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import Projects from Gitea"
|
||||
msgstr ""
|
||||
|
||||
|
@ -10632,6 +10645,9 @@ msgstr ""
|
|||
msgid "Import issues"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import issues from Jira"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import members"
|
||||
msgstr ""
|
||||
|
||||
|
@ -11144,6 +11160,9 @@ msgstr ""
|
|||
msgid "January"
|
||||
msgstr ""
|
||||
|
||||
msgid "Jira Issue Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "JiraService|Events for %{noteable_model_name} are disabled."
|
||||
msgstr ""
|
||||
|
||||
|
@ -12747,6 +12766,9 @@ msgstr ""
|
|||
msgid "Modal|Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Modified"
|
||||
msgstr ""
|
||||
|
||||
msgid "Modified in this version"
|
||||
msgstr ""
|
||||
|
||||
|
@ -13028,6 +13050,9 @@ msgstr ""
|
|||
msgid "New project"
|
||||
msgstr ""
|
||||
|
||||
msgid "New release"
|
||||
msgstr ""
|
||||
|
||||
msgid "New runners registration token has been generated!"
|
||||
msgstr ""
|
||||
|
||||
|
@ -13621,9 +13646,6 @@ msgstr ""
|
|||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Documentation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Selection"
|
||||
msgstr ""
|
||||
|
||||
|
@ -16295,6 +16317,9 @@ msgstr ""
|
|||
msgid "Releases are based on Git tags. We recommend naming tags that fit within semantic versioning, for example %{codeStart}v1.0%{codeEnd}, %{codeStart}v2.0-pre%{codeEnd}."
|
||||
msgstr ""
|
||||
|
||||
msgid "Releases documentation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Release|Something went wrong while getting the release details"
|
||||
msgstr ""
|
||||
|
||||
|
@ -18680,21 +18705,12 @@ msgstr ""
|
|||
msgid "Stage & Commit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stage all changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stage data updated"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stage removed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Staged"
|
||||
msgstr ""
|
||||
|
||||
msgid "Staged %{type}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging."
|
||||
msgstr ""
|
||||
|
||||
|
@ -19304,7 +19320,7 @@ msgstr ""
|
|||
msgid "TagsPage|Optionally, add a message to the tag. Leaving this blank creates a %{link_start}lightweight tag.%{link_end}"
|
||||
msgstr ""
|
||||
|
||||
msgid "TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page."
|
||||
msgid "TagsPage|Optionally, create a public Release of your project, based on this tag. Release notes are displayed on the %{releases_page_link_start}Releases%{link_end} page. %{docs_link_start}More information%{link_end}"
|
||||
msgstr ""
|
||||
|
||||
msgid "TagsPage|Release notes"
|
||||
|
@ -19851,6 +19867,9 @@ msgstr ""
|
|||
msgid "There are no archived projects yet"
|
||||
msgstr ""
|
||||
|
||||
msgid "There are no changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "There are no charts configured for this page"
|
||||
msgstr ""
|
||||
|
||||
|
@ -19887,12 +19906,6 @@ msgstr ""
|
|||
msgid "There are no projects shared with this group yet"
|
||||
msgstr ""
|
||||
|
||||
msgid "There are no staged changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "There are no unstaged changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "There is a limit of %{ci_project_subscriptions_limit} subscriptions from or to a project."
|
||||
msgstr ""
|
||||
|
||||
|
@ -21094,6 +21107,9 @@ msgstr ""
|
|||
msgid "Two-Factor Authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Two-Factor Authentication code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Two-factor Authentication"
|
||||
msgstr ""
|
||||
|
||||
|
@ -21304,18 +21320,6 @@ msgstr ""
|
|||
msgid "Unschedule job"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unstage"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unstage all changes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unstaged"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unstaged %{type}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unstar"
|
||||
msgstr ""
|
||||
|
||||
|
@ -22799,6 +22803,9 @@ msgstr ""
|
|||
msgid "You can only transfer the project to namespaces you manage."
|
||||
msgstr ""
|
||||
|
||||
msgid "You can only upload one design when dropping onto an existing design."
|
||||
msgstr ""
|
||||
|
||||
msgid "You can resolve the merge conflict using either the Interactive mode, by choosing %{use_ours} or %{use_theirs} buttons, or by editing the files directly. Commit these changes into %{branch_name}"
|
||||
msgstr ""
|
||||
|
||||
|
@ -22952,6 +22959,9 @@ msgstr ""
|
|||
msgid "You must set up incoming email before it becomes active."
|
||||
msgstr ""
|
||||
|
||||
msgid "You must upload a file with the same file name when dropping onto an existing design."
|
||||
msgstr ""
|
||||
|
||||
msgid "You need a different license to enable FileLocks feature"
|
||||
msgstr ""
|
||||
|
||||
|
@ -22988,7 +22998,7 @@ msgstr ""
|
|||
msgid "You will lose all changes you've made to this file. This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "You will lose all the unstaged changes you've made in this project. This action cannot be undone."
|
||||
msgid "You will lose all uncommitted changes you've made in this project. This action cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "You will need to update your local repositories to point to the new location."
|
||||
|
|
|
@ -68,7 +68,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
|
|||
# triggering the auth form will request admin mode
|
||||
get :new
|
||||
|
||||
post :create, params: { password: user.password }
|
||||
post :create, params: { user: { password: user.password } }
|
||||
|
||||
expect(response).to redirect_to admin_root_path
|
||||
expect(controller.current_user_mode.admin_mode?).to be(true)
|
||||
|
@ -82,7 +82,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
|
|||
# triggering the auth form will request admin mode
|
||||
get :new
|
||||
|
||||
post :create, params: { password: '' }
|
||||
post :create, params: { user: { password: '' } }
|
||||
|
||||
expect(response).to render_template :new
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
@ -95,7 +95,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
|
|||
|
||||
# do not trigger the auth form
|
||||
|
||||
post :create, params: { password: user.password }
|
||||
post :create, params: { user: { password: user.password } }
|
||||
|
||||
expect(response).to redirect_to(new_admin_session_path)
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
@ -110,12 +110,118 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
|
|||
get :new
|
||||
|
||||
Timecop.freeze(Gitlab::Auth::CurrentUserMode::ADMIN_MODE_REQUESTED_GRACE_PERIOD.from_now) do
|
||||
post :create, params: { password: user.password }
|
||||
post :create, params: { user: { password: user.password } }
|
||||
|
||||
expect(response).to redirect_to(new_admin_session_path)
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using two-factor authentication via OTP' do
|
||||
let(:user) { create(:admin, :two_factor) }
|
||||
|
||||
def authenticate_2fa(user_params)
|
||||
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
|
||||
end
|
||||
|
||||
it 'requests two factor after a valid password is provided' do
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
# triggering the auth form will request admin mode
|
||||
get :new
|
||||
|
||||
post :create, params: { user: { password: user.password } }
|
||||
|
||||
expect(response).to render_template('admin/sessions/two_factor')
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
end
|
||||
|
||||
it 'can login with valid otp' do
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
controller.store_location_for(:redirect, admin_root_path)
|
||||
controller.current_user_mode.request_admin_mode!
|
||||
|
||||
authenticate_2fa(otp_attempt: user.current_otp)
|
||||
|
||||
expect(response).to redirect_to admin_root_path
|
||||
expect(controller.current_user_mode.admin_mode?).to be(true)
|
||||
end
|
||||
|
||||
it 'cannot login with invalid otp' do
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
controller.current_user_mode.request_admin_mode!
|
||||
|
||||
authenticate_2fa(otp_attempt: 'invalid')
|
||||
|
||||
expect(response).to render_template('admin/sessions/two_factor')
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
end
|
||||
|
||||
context 'with password authentication disabled' do
|
||||
before do
|
||||
stub_application_setting(password_authentication_enabled_for_web: false)
|
||||
end
|
||||
|
||||
it 'allows 2FA stage of non-password login' do
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
controller.store_location_for(:redirect, admin_root_path)
|
||||
controller.current_user_mode.request_admin_mode!
|
||||
|
||||
authenticate_2fa(otp_attempt: user.current_otp)
|
||||
|
||||
expect(response).to redirect_to admin_root_path
|
||||
expect(controller.current_user_mode.admin_mode?).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when using two-factor authentication via U2F' do
|
||||
let(:user) { create(:admin, :two_factor_via_u2f) }
|
||||
|
||||
def authenticate_2fa_u2f(user_params)
|
||||
post(:create, params: { user: user_params }, session: { otp_user_id: user.id })
|
||||
end
|
||||
|
||||
it 'requests two factor after a valid password is provided' do
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
# triggering the auth form will request admin mode
|
||||
get :new
|
||||
post :create, params: { user: { password: user.password } }
|
||||
|
||||
expect(response).to render_template('admin/sessions/two_factor')
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
end
|
||||
|
||||
it 'can login with valid auth' do
|
||||
allow(U2fRegistration).to receive(:authenticate).and_return(true)
|
||||
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
controller.store_location_for(:redirect, admin_root_path)
|
||||
controller.current_user_mode.request_admin_mode!
|
||||
|
||||
authenticate_2fa_u2f(login: user.username, device_response: '{}')
|
||||
|
||||
expect(response).to redirect_to admin_root_path
|
||||
expect(controller.current_user_mode.admin_mode?).to be(true)
|
||||
end
|
||||
|
||||
it 'cannot login with invalid auth' do
|
||||
allow(U2fRegistration).to receive(:authenticate).and_return(false)
|
||||
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
controller.current_user_mode.request_admin_mode!
|
||||
authenticate_2fa_u2f(login: user.username, device_response: '{}')
|
||||
|
||||
expect(response).to render_template('admin/sessions/two_factor')
|
||||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -136,7 +242,7 @@ describe Admin::SessionsController, :do_not_mock_admin_mode do
|
|||
expect(controller.current_user_mode.admin_mode?).to be(false)
|
||||
|
||||
get :new
|
||||
post :create, params: { password: user.password }
|
||||
post :create, params: { user: { password: user.password } }
|
||||
expect(controller.current_user_mode.admin_mode?).to be(true)
|
||||
|
||||
post :destroy
|
||||
|
|
173
spec/controllers/projects/import/jira_controller_spec.rb
Normal file
173
spec/controllers/projects/import/jira_controller_spec.rb
Normal file
|
@ -0,0 +1,173 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe Projects::Import::JiraController do
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:project) { create(:project) }
|
||||
|
||||
context 'with anonymous user' do
|
||||
before do
|
||||
stub_feature_flags(jira_issue_import: true)
|
||||
end
|
||||
|
||||
context 'get show' do
|
||||
it 'redirects to issues page' do
|
||||
get :show, params: { namespace_id: project.namespace, project_id: project }
|
||||
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'post import' do
|
||||
it 'redirects to issues page' do
|
||||
post :import, params: { namespace_id: project.namespace, project_id: project, jira_project_key: 'Test' }
|
||||
|
||||
expect(response).to redirect_to(new_user_session_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with logged in user' do
|
||||
before do
|
||||
sign_in(user)
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
context 'when feature flag not enabled' do
|
||||
before do
|
||||
stub_feature_flags(jira_issue_import: false)
|
||||
end
|
||||
|
||||
context 'get show' do
|
||||
it 'redirects to issues page' do
|
||||
get :show, params: { namespace_id: project.namespace, project_id: project }
|
||||
|
||||
expect(response).to redirect_to(project_issues_path(project))
|
||||
end
|
||||
end
|
||||
|
||||
context 'post import' do
|
||||
it 'redirects to issues page' do
|
||||
post :import, params: { namespace_id: project.namespace, project_id: project, jira_project_key: 'Test' }
|
||||
|
||||
expect(response).to redirect_to(project_issues_path(project))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature flag enabled' do
|
||||
before do
|
||||
stub_feature_flags(jira_issue_import: true)
|
||||
end
|
||||
|
||||
context 'when jira service is enabled for the project' do
|
||||
let_it_be(:jira_service) { create(:jira_service, project: project) }
|
||||
|
||||
context 'when running jira import first time' do
|
||||
context 'get show' do
|
||||
it 'renders show template' do
|
||||
allow(JIRA::Resource::Project).to receive(:all).and_return([])
|
||||
expect(project.import_state).to be_nil
|
||||
|
||||
get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
|
||||
|
||||
expect(response).to render_template :show
|
||||
end
|
||||
end
|
||||
|
||||
context 'post import' do
|
||||
it 'creates import state' do
|
||||
expect(project.import_state).to be_nil
|
||||
|
||||
post :import, params: { namespace_id: project.namespace, project_id: project, jira_project_key: 'Test' }
|
||||
|
||||
project.reload
|
||||
|
||||
jira_project = project.import_data.data.dig('jira', 'projects').first
|
||||
expect(project.import_type).to eq 'jira'
|
||||
expect(project.import_state.status).to eq 'scheduled'
|
||||
expect(jira_project['key']).to eq 'Test'
|
||||
expect(response).to redirect_to(project_import_jira_path(project))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when import state is scheduled' do
|
||||
let_it_be(:import_state) { create(:import_state, project: project, status: :scheduled) }
|
||||
|
||||
context 'get show' do
|
||||
it 'renders import status' do
|
||||
get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
|
||||
|
||||
expect(project.import_state.status).to eq 'scheduled'
|
||||
expect(flash.now[:notice]).to eq 'Import scheduled'
|
||||
end
|
||||
end
|
||||
|
||||
context 'post import' do
|
||||
before do
|
||||
project.reload
|
||||
project.create_import_data(
|
||||
data: {
|
||||
'jira': {
|
||||
'projects': [{ 'key': 'Test', scheduled_at: 5.days.ago, scheduled_by: { user_id: user.id, name: user.name } }]
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses the existing import data' do
|
||||
expect(controller).not_to receive(:schedule_import)
|
||||
|
||||
post :import, params: { namespace_id: project.namespace, project_id: project, jira_project_key: 'New Project' }
|
||||
|
||||
expect(response).to redirect_to(project_import_jira_path(project))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when jira import ran before' do
|
||||
let_it_be(:import_state) { create(:import_state, project: project, status: :finished) }
|
||||
|
||||
context 'get show' do
|
||||
it 'renders import status' do
|
||||
allow(JIRA::Resource::Project).to receive(:all).and_return([])
|
||||
get :show, params: { namespace_id: project.namespace.to_param, project_id: project }
|
||||
|
||||
expect(project.import_state.status).to eq 'finished'
|
||||
expect(flash.now[:notice]).to eq 'Import finished'
|
||||
end
|
||||
end
|
||||
|
||||
context 'post import' do
|
||||
before do
|
||||
project.reload
|
||||
project.create_import_data(
|
||||
data: {
|
||||
'jira': {
|
||||
'projects': [{ 'key': 'Test', scheduled_at: 5.days.ago, scheduled_by: { user_id: user.id, name: user.name } }]
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'uses the existing import data' do
|
||||
expect(controller).to receive(:schedule_import).and_call_original
|
||||
|
||||
post :import, params: { namespace_id: project.namespace, project_id: project, jira_project_key: 'New Project' }
|
||||
|
||||
project.reload
|
||||
expect(project.import_state.status).to eq 'scheduled'
|
||||
jira_imported_projects = project.import_data.data.dig('jira', 'projects')
|
||||
expect(jira_imported_projects.size).to eq 2
|
||||
expect(jira_imported_projects.first['key']).to eq 'Test'
|
||||
expect(jira_imported_projects.last['key']).to eq 'New Project'
|
||||
expect(response).to redirect_to(project_import_jira_path(project))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
184
spec/features/admin/admin_mode/login_spec.rb
Normal file
184
spec/features/admin/admin_mode/login_spec.rb
Normal file
|
@ -0,0 +1,184 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe 'Admin Mode Login', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode do
|
||||
include TermsHelper
|
||||
include UserLoginHelper
|
||||
|
||||
describe 'with two-factor authentication', :js do
|
||||
def enter_code(code)
|
||||
fill_in 'user_otp_attempt', with: code
|
||||
click_button 'Verify code'
|
||||
end
|
||||
|
||||
context 'with valid username/password' do
|
||||
let(:user) { create(:admin, :two_factor) }
|
||||
|
||||
context 'using one-time code' do
|
||||
it 'blocks login if we reuse the same code immediately' do
|
||||
gitlab_sign_in(user, remember: true)
|
||||
|
||||
expect(page).to have_content('Two-Factor Authentication')
|
||||
|
||||
repeated_otp = user.current_otp
|
||||
enter_code(repeated_otp)
|
||||
gitlab_enable_admin_mode_sign_in(user)
|
||||
|
||||
expect(page).to have_content('Two-Factor Authentication')
|
||||
|
||||
enter_code(repeated_otp)
|
||||
|
||||
expect(current_path).to eq admin_session_path
|
||||
expect(page).to have_content('Invalid two-factor code')
|
||||
end
|
||||
|
||||
context 'not re-using codes' do
|
||||
before do
|
||||
gitlab_sign_in(user, remember: true)
|
||||
|
||||
expect(page).to have_content('Two-Factor Authentication')
|
||||
|
||||
enter_code(user.current_otp)
|
||||
gitlab_enable_admin_mode_sign_in(user)
|
||||
|
||||
expect(page).to have_content('Two-Factor Authentication')
|
||||
end
|
||||
|
||||
it 'allows login with valid code' do
|
||||
# Cannot reuse the TOTP
|
||||
Timecop.travel(30.seconds.from_now) do
|
||||
enter_code(user.current_otp)
|
||||
|
||||
expect(current_path).to eq admin_root_path
|
||||
expect(page).to have_content('Admin mode enabled')
|
||||
end
|
||||
end
|
||||
|
||||
it 'blocks login with invalid code' do
|
||||
# Cannot reuse the TOTP
|
||||
Timecop.travel(30.seconds.from_now) do
|
||||
enter_code('foo')
|
||||
|
||||
expect(page).to have_content('Invalid two-factor code')
|
||||
end
|
||||
end
|
||||
|
||||
it 'allows login with invalid code, then valid code' do
|
||||
# Cannot reuse the TOTP
|
||||
Timecop.travel(30.seconds.from_now) do
|
||||
enter_code('foo')
|
||||
|
||||
expect(page).to have_content('Invalid two-factor code')
|
||||
|
||||
enter_code(user.current_otp)
|
||||
|
||||
expect(current_path).to eq admin_root_path
|
||||
expect(page).to have_content('Admin mode enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'using backup code' do
|
||||
let(:codes) { user.generate_otp_backup_codes! }
|
||||
|
||||
before do
|
||||
expect(codes.size).to eq 10
|
||||
|
||||
# Ensure the generated codes get saved
|
||||
user.save
|
||||
end
|
||||
|
||||
context 'with valid code' do
|
||||
it 'allows login' do
|
||||
enter_code(codes.sample)
|
||||
|
||||
expect(current_path).to eq admin_root_path
|
||||
expect(page).to have_content('Admin mode enabled')
|
||||
end
|
||||
|
||||
it 'invalidates the used code' do
|
||||
expect { enter_code(codes.sample) }
|
||||
.to change { user.reload.otp_backup_codes.size }.by(-1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid code' do
|
||||
it 'blocks login' do
|
||||
code = codes.sample
|
||||
expect(user.invalidate_otp_backup_code!(code)).to eq true
|
||||
|
||||
user.save!
|
||||
expect(user.reload.otp_backup_codes.size).to eq 9
|
||||
|
||||
enter_code(code)
|
||||
|
||||
expect(page).to have_content('Invalid two-factor code.')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when logging in via omniauth' do
|
||||
let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml')}
|
||||
let(:mock_saml_response) do
|
||||
File.read('spec/fixtures/authentication/saml_response.xml')
|
||||
end
|
||||
|
||||
before do
|
||||
stub_omniauth_saml_config(enabled: true, auto_link_saml_user: true, allow_single_sign_on: ['saml'],
|
||||
providers: [mock_saml_config_with_upstream_two_factor_authn_contexts])
|
||||
end
|
||||
|
||||
context 'when authn_context is worth two factors' do
|
||||
let(:mock_saml_response) do
|
||||
File.read('spec/fixtures/authentication/saml_response.xml')
|
||||
.gsub('urn:oasis:names:tc:SAML:2.0:ac:classes:Password',
|
||||
'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS')
|
||||
end
|
||||
|
||||
it 'signs user in without prompting for second factor' do
|
||||
sign_in_using_saml!
|
||||
|
||||
expect(page).not_to have_content('Two-Factor Authentication')
|
||||
|
||||
enable_admin_mode_using_saml!
|
||||
|
||||
expect(page).not_to have_content('Two-Factor Authentication')
|
||||
expect(current_path).to eq admin_root_path
|
||||
expect(page).to have_content('Admin mode enabled')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when two factor authentication is required' do
|
||||
it 'shows 2FA prompt after omniauth login' do
|
||||
sign_in_using_saml!
|
||||
|
||||
expect(page).to have_content('Two-Factor Authentication')
|
||||
enter_code(user.current_otp)
|
||||
|
||||
enable_admin_mode_using_saml!
|
||||
|
||||
expect(page).to have_content('Two-Factor Authentication')
|
||||
|
||||
# Cannot reuse the TOTP
|
||||
Timecop.travel(30.seconds.from_now) do
|
||||
enter_code(user.current_otp)
|
||||
|
||||
expect(current_path).to eq admin_root_path
|
||||
expect(page).to have_content('Admin mode enabled')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def sign_in_using_saml!
|
||||
gitlab_sign_in_via('saml', user, 'my-uid', mock_saml_response)
|
||||
end
|
||||
|
||||
def enable_admin_mode_using_saml!
|
||||
gitlab_enable_admin_mode_sign_in_via('saml', user, 'my-uid', mock_saml_response)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
42
spec/features/admin/admin_mode/logout_spec.rb
Normal file
42
spec/features/admin/admin_mode/logout_spec.rb
Normal file
|
@ -0,0 +1,42 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
describe 'Admin Mode Logout', :js, :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode do
|
||||
include TermsHelper
|
||||
include UserLoginHelper
|
||||
|
||||
let(:user) { create(:admin) }
|
||||
|
||||
before do
|
||||
gitlab_sign_in(user)
|
||||
gitlab_enable_admin_mode_sign_in(user)
|
||||
visit admin_root_path
|
||||
end
|
||||
|
||||
it 'disable removes admin mode and redirects to root page' do
|
||||
gitlab_disable_admin_mode
|
||||
|
||||
expect(current_path).to eq root_path
|
||||
expect(page).to have_link(href: new_admin_session_path)
|
||||
end
|
||||
|
||||
it 'disable shows flash notice' do
|
||||
gitlab_disable_admin_mode
|
||||
|
||||
expect(page).to have_selector('.flash-notice')
|
||||
end
|
||||
|
||||
context 'on a read-only instance' do
|
||||
before do
|
||||
allow(Gitlab::Database).to receive(:read_only?).and_return(true)
|
||||
end
|
||||
|
||||
it 'disable removes admin mode and redirects to root page' do
|
||||
gitlab_disable_admin_mode
|
||||
|
||||
expect(current_path).to eq root_path
|
||||
expect(page).to have_link(href: new_admin_session_path)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -45,7 +45,7 @@ describe 'Admin mode', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode
|
|||
it 'can enter admin mode' do
|
||||
visit new_admin_session_path
|
||||
|
||||
fill_in 'password', with: admin.password
|
||||
fill_in 'user_password', with: admin.password
|
||||
|
||||
click_button 'Enter Admin Mode'
|
||||
|
||||
|
@ -60,7 +60,7 @@ describe 'Admin mode', :clean_gitlab_redis_shared_state, :do_not_mock_admin_mode
|
|||
it 'can enter admin mode' do
|
||||
visit new_admin_session_path
|
||||
|
||||
fill_in 'password', with: admin.password
|
||||
fill_in 'user_password', with: admin.password
|
||||
|
||||
click_button 'Enter Admin Mode'
|
||||
|
||||
|
|
|
@ -14,7 +14,6 @@ describe('IDE commit editor header', () => {
|
|||
|
||||
const findDiscardModal = () => wrapper.find({ ref: 'discardModal' });
|
||||
const findDiscardButton = () => wrapper.find({ ref: 'discardButton' });
|
||||
const findActionButton = () => wrapper.find({ ref: 'actionButton' });
|
||||
|
||||
beforeEach(() => {
|
||||
f = file('file');
|
||||
|
@ -28,9 +27,7 @@ describe('IDE commit editor header', () => {
|
|||
},
|
||||
});
|
||||
|
||||
jest.spyOn(wrapper.vm, 'stageChange').mockImplementation();
|
||||
jest.spyOn(wrapper.vm, 'unstageChange').mockImplementation();
|
||||
jest.spyOn(wrapper.vm, 'discardFileChanges').mockImplementation();
|
||||
jest.spyOn(wrapper.vm, 'discardChanges').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -38,8 +35,8 @@ describe('IDE commit editor header', () => {
|
|||
wrapper = null;
|
||||
});
|
||||
|
||||
it('renders button to discard & stage', () => {
|
||||
expect(wrapper.vm.$el.querySelectorAll('.btn').length).toBe(2);
|
||||
it('renders button to discard', () => {
|
||||
expect(wrapper.vm.$el.querySelectorAll('.btn')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('discard button', () => {
|
||||
|
@ -60,23 +57,7 @@ describe('IDE commit editor header', () => {
|
|||
it('calls discardFileChanges if dialog result is confirmed', () => {
|
||||
modal.vm.$emit('ok');
|
||||
|
||||
expect(wrapper.vm.discardFileChanges).toHaveBeenCalledWith(f.path);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stage/unstage button', () => {
|
||||
it('unstages the file if it was already staged', () => {
|
||||
f.staged = true;
|
||||
|
||||
findActionButton().trigger('click');
|
||||
|
||||
expect(wrapper.vm.unstageChange).toHaveBeenCalledWith(f.path);
|
||||
});
|
||||
|
||||
it('stages the file if it was not staged', () => {
|
||||
findActionButton().trigger('click');
|
||||
|
||||
expect(wrapper.vm.stageChange).toHaveBeenCalledWith(f.path);
|
||||
expect(wrapper.vm.discardChanges).toHaveBeenCalledWith(f.path);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import Vue from 'vue';
|
||||
import { GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
|
||||
import { GlSprintf, GlDropdown, GlDropdownItem, GlSearchBoxByClick } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue';
|
||||
import EnvironmentLogs from '~/logs/components/environment_logs.vue';
|
||||
|
@ -20,9 +20,18 @@ import {
|
|||
|
||||
jest.mock('~/lib/utils/scroll_utils');
|
||||
|
||||
const module = 'environmentLogs';
|
||||
|
||||
jest.mock('lodash/throttle', () =>
|
||||
jest.fn(func => {
|
||||
return func;
|
||||
}),
|
||||
);
|
||||
|
||||
describe('EnvironmentLogs', () => {
|
||||
let EnvironmentLogsComponent;
|
||||
let store;
|
||||
let dispatch;
|
||||
let wrapper;
|
||||
let state;
|
||||
|
||||
|
@ -32,14 +41,6 @@ describe('EnvironmentLogs', () => {
|
|||
clusterApplicationsDocumentationPath: mockDocumentationPath,
|
||||
};
|
||||
|
||||
const actionMocks = {
|
||||
setInitData: jest.fn(),
|
||||
setSearch: jest.fn(),
|
||||
showPodLogs: jest.fn(),
|
||||
showEnvironment: jest.fn(),
|
||||
fetchEnvironments: jest.fn(),
|
||||
};
|
||||
|
||||
const updateControlBtnsMock = jest.fn();
|
||||
|
||||
const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown');
|
||||
|
@ -47,24 +48,25 @@ describe('EnvironmentLogs', () => {
|
|||
const findSearchBar = () => wrapper.find('.js-logs-search');
|
||||
const findTimeRangePicker = () => wrapper.find({ ref: 'dateTimePicker' });
|
||||
const findInfoAlert = () => wrapper.find('.js-elasticsearch-alert');
|
||||
|
||||
const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' });
|
||||
|
||||
const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' });
|
||||
const findLogTrace = () => wrapper.find('.js-log-trace');
|
||||
const findLogFooter = () => wrapper.find({ ref: 'logFooter' });
|
||||
const getInfiniteScrollAttr = attr => parseInt(findInfiniteScroll().attributes(attr), 10);
|
||||
|
||||
const mockSetInitData = () => {
|
||||
state.pods.options = mockPods;
|
||||
state.environments.current = mockEnvName;
|
||||
[state.pods.current] = state.pods.options;
|
||||
|
||||
state.logs.isComplete = false;
|
||||
state.logs.lines = mockLogsResult;
|
||||
state.logs.lines = [];
|
||||
};
|
||||
|
||||
const mockShowPodLogs = podName => {
|
||||
const mockShowPodLogs = () => {
|
||||
state.pods.options = mockPods;
|
||||
[state.pods.current] = podName;
|
||||
[state.pods.current] = mockPods;
|
||||
|
||||
state.logs.isComplete = false;
|
||||
state.logs.lines = mockLogsResult;
|
||||
};
|
||||
|
||||
|
@ -83,10 +85,21 @@ describe('EnvironmentLogs', () => {
|
|||
methods: {
|
||||
update: updateControlBtnsMock,
|
||||
},
|
||||
props: {
|
||||
scrollDownButtonDisabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
...actionMocks,
|
||||
GlInfiniteScroll: {
|
||||
name: 'gl-infinite-scroll',
|
||||
template: `
|
||||
<div>
|
||||
<slot name="header"></slot>
|
||||
<slot name="items"></slot>
|
||||
<slot></slot>
|
||||
</div>
|
||||
`,
|
||||
},
|
||||
GlSprintf,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -95,12 +108,14 @@ describe('EnvironmentLogs', () => {
|
|||
store = createStore();
|
||||
state = store.state.environmentLogs;
|
||||
EnvironmentLogsComponent = Vue.extend(EnvironmentLogs);
|
||||
|
||||
jest.spyOn(store, 'dispatch').mockResolvedValue();
|
||||
|
||||
dispatch = store.dispatch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
actionMocks.setInitData.mockReset();
|
||||
actionMocks.showPodLogs.mockReset();
|
||||
actionMocks.fetchEnvironments.mockReset();
|
||||
store.dispatch.mockReset();
|
||||
|
||||
if (wrapper) {
|
||||
wrapper.destroy();
|
||||
|
@ -124,14 +139,14 @@ describe('EnvironmentLogs', () => {
|
|||
expect(findTimeRangePicker().is(DateTimePicker)).toBe(true);
|
||||
|
||||
// log trace
|
||||
expect(findLogTrace().isEmpty()).toBe(false);
|
||||
expect(findInfiniteScroll().exists()).toBe(true);
|
||||
expect(findLogTrace().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('mounted inits data', () => {
|
||||
initWrapper();
|
||||
|
||||
expect(actionMocks.setInitData).toHaveBeenCalledTimes(1);
|
||||
expect(actionMocks.setInitData).toHaveBeenLastCalledWith({
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, {
|
||||
timeRange: expect.objectContaining({
|
||||
default: true,
|
||||
}),
|
||||
|
@ -139,18 +154,15 @@ describe('EnvironmentLogs', () => {
|
|||
podName: null,
|
||||
});
|
||||
|
||||
expect(actionMocks.fetchEnvironments).toHaveBeenCalledTimes(1);
|
||||
expect(actionMocks.fetchEnvironments).toHaveBeenLastCalledWith(mockEnvironmentsEndpoint);
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/fetchEnvironments`, mockEnvironmentsEndpoint);
|
||||
});
|
||||
|
||||
describe('loading state', () => {
|
||||
beforeEach(() => {
|
||||
state.pods.options = [];
|
||||
|
||||
state.logs = {
|
||||
lines: [],
|
||||
isLoading: true,
|
||||
};
|
||||
state.logs.lines = [];
|
||||
state.logs.isLoading = true;
|
||||
|
||||
state.environments = {
|
||||
options: [],
|
||||
|
@ -183,6 +195,18 @@ describe('EnvironmentLogs', () => {
|
|||
expect(updateControlBtnsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows an infinite scroll with height and no content', () => {
|
||||
expect(getInfiniteScrollAttr('max-list-height')).toBeGreaterThan(0);
|
||||
expect(getInfiniteScrollAttr('fetched-items')).toBe(0);
|
||||
});
|
||||
|
||||
it('shows an infinite scroll container with equal height and max-height ', () => {
|
||||
const height = getInfiniteScrollAttr('max-list-height');
|
||||
|
||||
expect(height).toEqual(expect.any(Number));
|
||||
expect(findInfiniteScroll().attributes('style')).toMatch(`height: ${height}px;`);
|
||||
});
|
||||
|
||||
it('shows a logs trace', () => {
|
||||
expect(findLogTrace().text()).toBe('');
|
||||
expect(
|
||||
|
@ -193,14 +217,12 @@ describe('EnvironmentLogs', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('legacy environment', () => {
|
||||
describe('k8s environment', () => {
|
||||
beforeEach(() => {
|
||||
state.pods.options = [];
|
||||
|
||||
state.logs = {
|
||||
lines: [],
|
||||
isLoading: false,
|
||||
};
|
||||
state.logs.lines = [];
|
||||
state.logs.isLoading = false;
|
||||
|
||||
state.environments = {
|
||||
options: mockEnvironments,
|
||||
|
@ -226,9 +248,16 @@ describe('EnvironmentLogs', () => {
|
|||
|
||||
describe('state with data', () => {
|
||||
beforeEach(() => {
|
||||
actionMocks.setInitData.mockImplementation(mockSetInitData);
|
||||
actionMocks.showPodLogs.mockImplementation(mockShowPodLogs);
|
||||
actionMocks.fetchEnvironments.mockImplementation(mockFetchEnvs);
|
||||
dispatch.mockImplementation(actionName => {
|
||||
if (actionName === `${module}/setInitData`) {
|
||||
mockSetInitData();
|
||||
} else if (actionName === `${module}/showPodLogs`) {
|
||||
mockShowPodLogs();
|
||||
} else if (actionName === `${module}/fetchEnvironments`) {
|
||||
mockFetchEnvs();
|
||||
mockShowPodLogs();
|
||||
}
|
||||
});
|
||||
|
||||
initWrapper();
|
||||
});
|
||||
|
@ -236,10 +265,6 @@ describe('EnvironmentLogs', () => {
|
|||
afterEach(() => {
|
||||
scrollDown.mockReset();
|
||||
updateControlBtnsMock.mockReset();
|
||||
|
||||
actionMocks.setInitData.mockReset();
|
||||
actionMocks.showPodLogs.mockReset();
|
||||
actionMocks.fetchEnvironments.mockReset();
|
||||
});
|
||||
|
||||
it('displays an enabled search bar', () => {
|
||||
|
@ -249,8 +274,8 @@ describe('EnvironmentLogs', () => {
|
|||
findSearchBar().vm.$emit('input', mockSearch);
|
||||
findSearchBar().vm.$emit('submit');
|
||||
|
||||
expect(actionMocks.setSearch).toHaveBeenCalledTimes(1);
|
||||
expect(actionMocks.setSearch).toHaveBeenCalledWith(mockSearch);
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/setInitData`, expect.any(Object));
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/setSearch`, mockSearch);
|
||||
});
|
||||
|
||||
it('displays an enabled time window dropdown', () => {
|
||||
|
@ -282,18 +307,21 @@ describe('EnvironmentLogs', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows infinite scroll with height and no content', () => {
|
||||
expect(getInfiniteScrollAttr('max-list-height')).toBeGreaterThan(0);
|
||||
expect(getInfiniteScrollAttr('fetched-items')).toBe(mockTrace.length);
|
||||
});
|
||||
|
||||
it('populates logs trace', () => {
|
||||
const trace = findLogTrace();
|
||||
expect(trace.text().split('\n').length).toBe(mockTrace.length);
|
||||
expect(trace.text().split('\n')).toEqual(mockTrace);
|
||||
});
|
||||
|
||||
it('update control buttons state', () => {
|
||||
expect(updateControlBtnsMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('populates footer', () => {
|
||||
const footer = findLogFooter().text();
|
||||
|
||||
it('scrolls to bottom when loaded', () => {
|
||||
expect(scrollDown).toHaveBeenCalledTimes(1);
|
||||
expect(footer).toContain(`${mockLogsResult.length} results`);
|
||||
});
|
||||
|
||||
describe('when user clicks', () => {
|
||||
|
@ -301,33 +329,99 @@ describe('EnvironmentLogs', () => {
|
|||
const items = findEnvironmentsDropdown().findAll(GlDropdownItem);
|
||||
const index = 1; // any env
|
||||
|
||||
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(0);
|
||||
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything());
|
||||
|
||||
items.at(index).vm.$emit('click');
|
||||
|
||||
expect(actionMocks.showEnvironment).toHaveBeenCalledTimes(1);
|
||||
expect(actionMocks.showEnvironment).toHaveBeenLastCalledWith(mockEnvironments[index].name);
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
`${module}/showEnvironment`,
|
||||
mockEnvironments[index].name,
|
||||
);
|
||||
});
|
||||
|
||||
it('pod name, trace is refreshed', () => {
|
||||
const items = findPodsDropdown().findAll(GlDropdownItem);
|
||||
const index = 2; // any pod
|
||||
|
||||
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0);
|
||||
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
|
||||
|
||||
items.at(index).vm.$emit('click');
|
||||
|
||||
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1);
|
||||
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPods[index]);
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPods[index]);
|
||||
});
|
||||
|
||||
it('refresh button, trace is refreshed', () => {
|
||||
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(0);
|
||||
expect(dispatch).not.toHaveBeenCalledWith(`${module}/showPodLogs`, expect.anything());
|
||||
|
||||
findLogControlButtons().vm.$emit('refresh');
|
||||
|
||||
expect(actionMocks.showPodLogs).toHaveBeenCalledTimes(1);
|
||||
expect(actionMocks.showPodLogs).toHaveBeenLastCalledWith(mockPodName);
|
||||
expect(dispatch).toHaveBeenCalledWith(`${module}/showPodLogs`, mockPodName);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listeners', () => {
|
||||
beforeEach(() => {
|
||||
initWrapper();
|
||||
});
|
||||
|
||||
it('attaches listeners in components', () => {
|
||||
expect(findInfiniteScroll().vm.$listeners).toEqual({
|
||||
topReached: expect.any(Function),
|
||||
scroll: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('`topReached` when not loading', () => {
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
|
||||
findInfiniteScroll().vm.$emit('topReached');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
});
|
||||
|
||||
it('`topReached` does not fetches more logs when already loading', () => {
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('topReached');
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
});
|
||||
|
||||
it('`topReached` fetches more logs', () => {
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('topReached');
|
||||
|
||||
expect(store.dispatch).not.toHaveBeenCalledWith(`${module}/fetchMoreLogsPrepend`, undefined);
|
||||
});
|
||||
|
||||
it('`scroll` on a scrollable target results in enabled scroll buttons', () => {
|
||||
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 21 };
|
||||
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('scroll', { target });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('`scroll` on a non-scrollable target in disabled scroll buttons', () => {
|
||||
const target = { scrollTop: 10, clientHeight: 10, scrollHeight: 20 };
|
||||
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('scroll', { target });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('`scroll` on no target results in disabled scroll buttons', () => {
|
||||
state.logs.isLoading = true;
|
||||
findInfiniteScroll().vm.$emit('scroll', { target: undefined });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(findLogControlButtons().props('scrollDownButtonDisabled')).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,15 +1,6 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import { GlButton } from '@gitlab/ui';
|
||||
import LogControlButtons from '~/logs/components/log_control_buttons.vue';
|
||||
import {
|
||||
canScroll,
|
||||
isScrolledToTop,
|
||||
isScrolledToBottom,
|
||||
scrollDown,
|
||||
scrollUp,
|
||||
} from '~/lib/utils/scroll_utils';
|
||||
|
||||
jest.mock('~/lib/utils/scroll_utils');
|
||||
|
||||
describe('LogControlButtons', () => {
|
||||
let wrapper;
|
||||
|
@ -18,8 +9,14 @@ describe('LogControlButtons', () => {
|
|||
const findScrollToBottom = () => wrapper.find('.js-scroll-to-bottom');
|
||||
const findRefreshBtn = () => wrapper.find('.js-refresh-log');
|
||||
|
||||
const initWrapper = () => {
|
||||
wrapper = shallowMount(LogControlButtons);
|
||||
const initWrapper = opts => {
|
||||
wrapper = shallowMount(LogControlButtons, {
|
||||
listeners: {
|
||||
scrollUp: () => {},
|
||||
scrollDown: () => {},
|
||||
},
|
||||
...opts,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -55,27 +52,16 @@ describe('LogControlButtons', () => {
|
|||
describe('when scrolling actions are enabled', () => {
|
||||
beforeEach(() => {
|
||||
// mock scrolled to the middle of a long page
|
||||
canScroll.mockReturnValue(true);
|
||||
isScrolledToBottom.mockReturnValue(false);
|
||||
isScrolledToTop.mockReturnValue(false);
|
||||
|
||||
initWrapper();
|
||||
wrapper.vm.update();
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
canScroll.mockReset();
|
||||
isScrolledToTop.mockReset();
|
||||
isScrolledToBottom.mockReset();
|
||||
});
|
||||
|
||||
it('click on "scroll to top" scrolls up', () => {
|
||||
expect(findScrollToTop().is('[disabled]')).toBe(false);
|
||||
|
||||
findScrollToTop().vm.$emit('click');
|
||||
|
||||
expect(scrollUp).toHaveBeenCalledTimes(1);
|
||||
expect(wrapper.emitted('scrollUp')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('click on "scroll to bottom" scrolls down', () => {
|
||||
|
@ -83,25 +69,23 @@ describe('LogControlButtons', () => {
|
|||
|
||||
findScrollToBottom().vm.$emit('click');
|
||||
|
||||
expect(scrollDown).toHaveBeenCalledTimes(1); // plus one time when trace was loaded
|
||||
expect(wrapper.emitted('scrollDown')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when scrolling actions are disabled', () => {
|
||||
beforeEach(() => {
|
||||
// mock a short page without a scrollbar
|
||||
canScroll.mockReturnValue(false);
|
||||
isScrolledToBottom.mockReturnValue(true);
|
||||
isScrolledToTop.mockReturnValue(true);
|
||||
|
||||
initWrapper();
|
||||
initWrapper({ listeners: {} });
|
||||
return wrapper.vm.$nextTick();
|
||||
});
|
||||
|
||||
it('buttons are disabled', () => {
|
||||
wrapper.vm.update();
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(findScrollToTop().is('[disabled]')).toBe(true);
|
||||
expect(findScrollToBottom().is('[disabled]')).toBe(true);
|
||||
expect(findScrollToTop().exists()).toBe(false);
|
||||
expect(findScrollToBottom().exists()).toBe(false);
|
||||
// This should be enabled when gitlab-ui contains:
|
||||
// https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1149
|
||||
// expect(findScrollToBottom().is('[disabled]')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
export const mockProjectPath = 'root/autodevops-deploy';
|
||||
const mockProjectPath = 'root/autodevops-deploy';
|
||||
|
||||
export const mockEnvName = 'production';
|
||||
export const mockEnvironmentsEndpoint = `${mockProjectPath}/environments.json`;
|
||||
export const mockEnvId = '99';
|
||||
export const mockDocumentationPath = '/documentation.md';
|
||||
export const mockLogsEndpoint = '/dummy_logs_path.json';
|
||||
export const mockCursor = 'MOCK_CURSOR';
|
||||
export const mockNextCursor = 'MOCK_NEXT_CURSOR';
|
||||
|
||||
const makeMockEnvironment = (id, name, advancedQuerying) => ({
|
||||
id,
|
||||
project_path: mockProjectPath,
|
||||
name,
|
||||
logs_api_path: '/dummy_logs_path.json',
|
||||
logs_api_path: mockLogsEndpoint,
|
||||
enable_advanced_logs_querying: advancedQuerying,
|
||||
});
|
||||
|
||||
|
@ -28,58 +32,22 @@ export const mockPods = [
|
|||
];
|
||||
|
||||
export const mockLogsResult = [
|
||||
{
|
||||
timestamp: '2019-12-13T13:43:18.2760123Z',
|
||||
message: '10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13',
|
||||
},
|
||||
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: '- -> /' },
|
||||
{
|
||||
timestamp: '2019-12-13T13:43:26.8420123Z',
|
||||
message: '10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13',
|
||||
},
|
||||
{ timestamp: '2019-12-13T13:43:26.8420123Z', message: '- -> /' },
|
||||
{
|
||||
timestamp: '2019-12-13T13:43:28.3710123Z',
|
||||
message: '10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
|
||||
},
|
||||
{ timestamp: '2019-12-13T13:43:28.3710123Z', message: '- -> /' },
|
||||
{
|
||||
timestamp: '2019-12-13T13:43:36.8860123Z',
|
||||
message: '10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
|
||||
},
|
||||
{ timestamp: '2019-12-13T13:43:36.8860123Z', message: '- -> /' },
|
||||
{
|
||||
timestamp: '2019-12-13T13:43:38.4000123Z',
|
||||
message: '10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
|
||||
},
|
||||
{ timestamp: '2019-12-13T13:43:38.4000123Z', message: '- -> /' },
|
||||
{
|
||||
timestamp: '2019-12-13T13:43:46.8420123Z',
|
||||
message: '10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
|
||||
},
|
||||
{ timestamp: '2019-12-13T13:43:46.8430123Z', message: '- -> /' },
|
||||
{
|
||||
timestamp: '2019-12-13T13:43:48.3240123Z',
|
||||
message: '10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
|
||||
},
|
||||
{ timestamp: '2019-12-13T13:43:48.3250123Z', message: '- -> /' },
|
||||
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: 'Log 1' },
|
||||
{ timestamp: '2019-12-13T13:43:18.2760123Z', message: 'Log 2' },
|
||||
{ timestamp: '2019-12-13T13:43:26.8420123Z', message: 'Log 3' },
|
||||
];
|
||||
|
||||
export const mockTrace = [
|
||||
'Dec 13 13:43:18.276Z | 10.36.0.1 - - [16/Oct/2019:06:29:48 UTC] "GET / HTTP/1.1" 200 13',
|
||||
'Dec 13 13:43:18.276Z | - -> /',
|
||||
'Dec 13 13:43:26.842Z | 10.36.0.1 - - [16/Oct/2019:06:29:57 UTC] "GET / HTTP/1.1" 200 13',
|
||||
'Dec 13 13:43:26.842Z | - -> /',
|
||||
'Dec 13 13:43:28.371Z | 10.36.0.1 - - [16/Oct/2019:06:29:58 UTC] "GET / HTTP/1.1" 200 13',
|
||||
'Dec 13 13:43:28.371Z | - -> /',
|
||||
'Dec 13 13:43:36.886Z | 10.36.0.1 - - [16/Oct/2019:06:30:07 UTC] "GET / HTTP/1.1" 200 13',
|
||||
'Dec 13 13:43:36.886Z | - -> /',
|
||||
'Dec 13 13:43:38.400Z | 10.36.0.1 - - [16/Oct/2019:06:30:08 UTC] "GET / HTTP/1.1" 200 13',
|
||||
'Dec 13 13:43:38.400Z | - -> /',
|
||||
'Dec 13 13:43:46.842Z | 10.36.0.1 - - [16/Oct/2019:06:30:17 UTC] "GET / HTTP/1.1" 200 13',
|
||||
'Dec 13 13:43:46.843Z | - -> /',
|
||||
'Dec 13 13:43:48.324Z | 10.36.0.1 - - [16/Oct/2019:06:30:18 UTC] "GET / HTTP/1.1" 200 13',
|
||||
'Dec 13 13:43:48.325Z | - -> /',
|
||||
'Dec 13 13:43:18.276Z | Log 1',
|
||||
'Dec 13 13:43:18.276Z | Log 2',
|
||||
'Dec 13 13:43:26.842Z | Log 3',
|
||||
];
|
||||
|
||||
export const mockResponse = {
|
||||
pod_name: mockPodName,
|
||||
pods: mockPods,
|
||||
logs: mockLogsResult,
|
||||
cursor: mockNextCursor,
|
||||
};
|
||||
|
||||
export const mockSearch = 'foo +bar';
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
showPodLogs,
|
||||
fetchEnvironments,
|
||||
fetchLogs,
|
||||
fetchMoreLogsPrepend,
|
||||
} from '~/logs/stores/actions';
|
||||
|
||||
import { defaultTimeRange } from '~/monitoring/constants';
|
||||
|
@ -18,7 +19,6 @@ import axios from '~/lib/utils/axios_utils';
|
|||
import flash from '~/flash';
|
||||
|
||||
import {
|
||||
mockProjectPath,
|
||||
mockPodName,
|
||||
mockEnvironmentsEndpoint,
|
||||
mockEnvironments,
|
||||
|
@ -26,6 +26,10 @@ import {
|
|||
mockLogsResult,
|
||||
mockEnvName,
|
||||
mockSearch,
|
||||
mockLogsEndpoint,
|
||||
mockResponse,
|
||||
mockCursor,
|
||||
mockNextCursor,
|
||||
} from '../mock_data';
|
||||
|
||||
jest.mock('~/flash');
|
||||
|
@ -52,6 +56,8 @@ describe('Logs Store actions', () => {
|
|||
let state;
|
||||
let mock;
|
||||
|
||||
const latestGetParams = () => mock.history.get[mock.history.get.length - 1].params;
|
||||
|
||||
convertToFixedRange.mockImplementation(range => {
|
||||
if (range === defaultTimeRange) {
|
||||
return { ...mockDefaultRange };
|
||||
|
@ -75,10 +81,16 @@ describe('Logs Store actions', () => {
|
|||
|
||||
describe('setInitData', () => {
|
||||
it('should commit environment and pod name mutation', () =>
|
||||
testAction(setInitData, { environmentName: mockEnvName, podName: mockPodName }, state, [
|
||||
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
]));
|
||||
testAction(
|
||||
setInitData,
|
||||
{ timeRange: mockFixedRange, environmentName: mockEnvName, podName: mockPodName },
|
||||
state,
|
||||
[
|
||||
{ type: types.SET_TIME_RANGE, payload: mockFixedRange },
|
||||
{ type: types.SET_PROJECT_ENVIRONMENT, payload: mockEnvName },
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
],
|
||||
));
|
||||
});
|
||||
|
||||
describe('setSearch', () => {
|
||||
|
@ -140,170 +152,213 @@ describe('Logs Store actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fetchLogs', () => {
|
||||
describe('when the backend responds succesfully', () => {
|
||||
let expectedMutations;
|
||||
let expectedActions;
|
||||
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
|
||||
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
|
||||
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined', () => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
state.pods.current = mockPodName;
|
||||
|
||||
const endpoint = '/dummy_logs_path.json';
|
||||
|
||||
mock
|
||||
.onGet(endpoint, {
|
||||
params: {
|
||||
pod_name: mockPodName,
|
||||
...mockDefaultRange,
|
||||
},
|
||||
})
|
||||
.reply(200, {
|
||||
pod_name: mockPodName,
|
||||
pods: mockPods,
|
||||
logs: mockLogsResult,
|
||||
});
|
||||
|
||||
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
|
||||
|
||||
return testAction(
|
||||
fetchLogs,
|
||||
null,
|
||||
state,
|
||||
[
|
||||
describe('fetchLogs', () => {
|
||||
beforeEach(() => {
|
||||
expectedMutations = [
|
||||
{ type: types.REQUEST_PODS_DATA },
|
||||
{ type: types.REQUEST_LOGS_DATA },
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
|
||||
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
|
||||
],
|
||||
[],
|
||||
);
|
||||
});
|
||||
{
|
||||
type: types.RECEIVE_LOGS_DATA_SUCCESS,
|
||||
payload: { logs: mockLogsResult, cursor: mockNextCursor },
|
||||
},
|
||||
];
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
|
||||
state.projectPath = mockProjectPath;
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
state.pods.current = mockPodName;
|
||||
state.timeRange.current = mockFixedRange;
|
||||
expectedActions = [];
|
||||
});
|
||||
|
||||
const endpoint = '/dummy_logs_path.json';
|
||||
it('should commit logs and pod data when there is pod name defined', () => {
|
||||
state.pods.current = mockPodName;
|
||||
|
||||
mock
|
||||
.onGet(endpoint, {
|
||||
params: {
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toMatchObject({
|
||||
pod_name: mockPodName,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.timeRange.current = mockFixedRange;
|
||||
state.logs.cursor = mockCursor;
|
||||
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
start: mockFixedRange.start,
|
||||
end: mockFixedRange.end,
|
||||
},
|
||||
})
|
||||
.reply(200, {
|
||||
pod_name: mockPodName,
|
||||
pods: mockPods,
|
||||
logs: mockLogsResult,
|
||||
cursor: mockCursor,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return testAction(
|
||||
fetchLogs,
|
||||
null,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_PODS_DATA },
|
||||
{ type: types.REQUEST_LOGS_DATA },
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
|
||||
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
|
||||
],
|
||||
[],
|
||||
);
|
||||
});
|
||||
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.search = mockSearch;
|
||||
state.timeRange.current = 'INVALID_TIME_RANGE';
|
||||
|
||||
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
state.pods.current = mockPodName;
|
||||
state.search = mockSearch;
|
||||
state.timeRange.current = 'INVALID_TIME_RANGE';
|
||||
|
||||
const endpoint = '/dummy_logs_path.json';
|
||||
|
||||
mock
|
||||
.onGet(endpoint, {
|
||||
params: {
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
search: mockSearch,
|
||||
},
|
||||
})
|
||||
.reply(200, {
|
||||
pod_name: mockPodName,
|
||||
pods: mockPods,
|
||||
logs: mockLogsResult,
|
||||
});
|
||||
|
||||
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
|
||||
|
||||
return testAction(
|
||||
fetchLogs,
|
||||
null,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_PODS_DATA },
|
||||
{ type: types.REQUEST_LOGS_DATA },
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
|
||||
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
|
||||
],
|
||||
[],
|
||||
() => {
|
||||
});
|
||||
// Warning about time ranges was issued
|
||||
expect(flash).toHaveBeenCalledTimes(1);
|
||||
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when no pod name defined', done => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
|
||||
const endpoint = '/dummy_logs_path.json';
|
||||
|
||||
mock.onGet(endpoint, { params: { ...mockDefaultRange } }).reply(200, {
|
||||
pod_name: mockPodName,
|
||||
pods: mockPods,
|
||||
logs: mockLogsResult,
|
||||
});
|
||||
});
|
||||
mock.onGet(endpoint).replyOnce(202); // mock reactive cache
|
||||
|
||||
testAction(
|
||||
fetchLogs,
|
||||
null,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_PODS_DATA },
|
||||
{ type: types.REQUEST_LOGS_DATA },
|
||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||
{ type: types.RECEIVE_PODS_DATA_SUCCESS, payload: mockPods },
|
||||
{ type: types.RECEIVE_LOGS_DATA_SUCCESS, payload: mockLogsResult },
|
||||
],
|
||||
[],
|
||||
done,
|
||||
);
|
||||
it('should commit logs and pod data when no pod name defined', () => {
|
||||
state.timeRange.current = mockDefaultRange;
|
||||
|
||||
return testAction(fetchLogs, null, state, expectedMutations, expectedActions, () => {
|
||||
expect(latestGetParams()).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should commit logs and pod errors when backend fails', () => {
|
||||
describe('fetchMoreLogsPrepend', () => {
|
||||
beforeEach(() => {
|
||||
expectedMutations = [
|
||||
{ type: types.REQUEST_LOGS_DATA_PREPEND },
|
||||
{
|
||||
type: types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS,
|
||||
payload: { logs: mockLogsResult, cursor: mockNextCursor },
|
||||
},
|
||||
];
|
||||
|
||||
expectedActions = [];
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined', () => {
|
||||
state.pods.current = mockPodName;
|
||||
|
||||
expectedActions = [];
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toMatchObject({
|
||||
pod_name: mockPodName,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name defined and a non-default date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.timeRange.current = mockFixedRange;
|
||||
state.logs.cursor = mockCursor;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
start: mockFixedRange.start,
|
||||
end: mockFixedRange.end,
|
||||
cursor: mockCursor,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when there is pod name and search and a faulty date range', () => {
|
||||
state.pods.current = mockPodName;
|
||||
state.search = mockSearch;
|
||||
state.timeRange.current = 'INVALID_TIME_RANGE';
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toEqual({
|
||||
pod_name: mockPodName,
|
||||
search: mockSearch,
|
||||
});
|
||||
// Warning about time ranges was issued
|
||||
expect(flash).toHaveBeenCalledTimes(1);
|
||||
expect(flash).toHaveBeenCalledWith(expect.any(String), 'warning');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should commit logs and pod data when no pod name defined', () => {
|
||||
state.timeRange.current = mockDefaultRange;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
expectedMutations,
|
||||
expectedActions,
|
||||
() => {
|
||||
expect(latestGetParams()).toEqual({});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should not commit logs or pod data when it has reached the end', () => {
|
||||
state.logs.isComplete = true;
|
||||
state.logs.cursor = null;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
[], // no mutations done
|
||||
[], // no actions dispatched
|
||||
() => {
|
||||
expect(mock.history.get).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the backend responds with an error', () => {
|
||||
beforeEach(() => {
|
||||
mock = new MockAdapter(axios);
|
||||
mock.onGet(mockLogsEndpoint).reply(500);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.reset();
|
||||
});
|
||||
|
||||
it('fetchLogs should commit logs and pod errors', () => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
|
||||
const endpoint = `/${mockProjectPath}/-/logs/elasticsearch.json?environment_name=${mockEnvName}`;
|
||||
mock.onGet(endpoint).replyOnce(500);
|
||||
|
||||
return testAction(
|
||||
fetchLogs,
|
||||
null,
|
||||
|
@ -316,7 +371,26 @@ describe('Logs Store actions', () => {
|
|||
],
|
||||
[],
|
||||
() => {
|
||||
expect(flash).toHaveBeenCalledTimes(1);
|
||||
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('fetchMoreLogsPrepend should commit logs and pod errors', () => {
|
||||
state.environments.options = mockEnvironments;
|
||||
state.environments.current = mockEnvName;
|
||||
|
||||
return testAction(
|
||||
fetchMoreLogsPrepend,
|
||||
null,
|
||||
state,
|
||||
[
|
||||
{ type: types.REQUEST_LOGS_DATA_PREPEND },
|
||||
{ type: types.RECEIVE_LOGS_DATA_PREPEND_ERROR },
|
||||
],
|
||||
[],
|
||||
() => {
|
||||
expect(mock.history.get[0].url).toBe(mockLogsEndpoint);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
@ -9,6 +9,8 @@ import {
|
|||
mockPodName,
|
||||
mockLogsResult,
|
||||
mockSearch,
|
||||
mockCursor,
|
||||
mockNextCursor,
|
||||
} from '../mock_data';
|
||||
|
||||
describe('Logs Store Mutations', () => {
|
||||
|
@ -73,27 +75,47 @@ describe('Logs Store Mutations', () => {
|
|||
it('starts loading for logs', () => {
|
||||
mutations[types.REQUEST_LOGS_DATA](state);
|
||||
|
||||
expect(state.logs).toEqual(
|
||||
expect.objectContaining({
|
||||
lines: [],
|
||||
isLoading: true,
|
||||
isComplete: false,
|
||||
}),
|
||||
);
|
||||
expect(state.timeRange.current).toEqual({
|
||||
start: expect.any(String),
|
||||
end: expect.any(String),
|
||||
});
|
||||
|
||||
expect(state.logs).toEqual({
|
||||
lines: [],
|
||||
cursor: null,
|
||||
isLoading: true,
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECEIVE_LOGS_DATA_SUCCESS', () => {
|
||||
it('receives logs lines', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, mockLogsResult);
|
||||
it('receives logs lines and cursor', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
|
||||
logs: mockLogsResult,
|
||||
cursor: mockCursor,
|
||||
});
|
||||
|
||||
expect(state.logs).toEqual(
|
||||
expect.objectContaining({
|
||||
lines: mockLogsResult,
|
||||
isLoading: false,
|
||||
isComplete: true,
|
||||
}),
|
||||
);
|
||||
expect(state.logs).toEqual({
|
||||
lines: mockLogsResult,
|
||||
isLoading: false,
|
||||
cursor: mockCursor,
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('receives logs lines and a null cursor to indicate the end', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_SUCCESS](state, {
|
||||
logs: mockLogsResult,
|
||||
cursor: null,
|
||||
});
|
||||
|
||||
expect(state.logs).toEqual({
|
||||
lines: mockLogsResult,
|
||||
isLoading: false,
|
||||
cursor: null,
|
||||
isComplete: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -101,13 +123,77 @@ describe('Logs Store Mutations', () => {
|
|||
it('receives log data error and stops loading', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_ERROR](state);
|
||||
|
||||
expect(state.logs).toEqual(
|
||||
expect.objectContaining({
|
||||
lines: [],
|
||||
isLoading: false,
|
||||
isComplete: true,
|
||||
}),
|
||||
);
|
||||
expect(state.logs).toEqual({
|
||||
lines: [],
|
||||
isLoading: false,
|
||||
cursor: null,
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('REQUEST_LOGS_DATA_PREPEND', () => {
|
||||
it('receives logs lines and cursor', () => {
|
||||
mutations[types.REQUEST_LOGS_DATA_PREPEND](state);
|
||||
|
||||
expect(state.logs.isLoading).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECEIVE_LOGS_DATA_PREPEND_SUCCESS', () => {
|
||||
it('receives logs lines and cursor', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
|
||||
logs: mockLogsResult,
|
||||
cursor: mockCursor,
|
||||
});
|
||||
|
||||
expect(state.logs).toEqual({
|
||||
lines: mockLogsResult,
|
||||
isLoading: false,
|
||||
cursor: mockCursor,
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('receives additional logs lines and a new cursor', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
|
||||
logs: mockLogsResult,
|
||||
cursor: mockCursor,
|
||||
});
|
||||
|
||||
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
|
||||
logs: mockLogsResult,
|
||||
cursor: mockNextCursor,
|
||||
});
|
||||
|
||||
expect(state.logs).toEqual({
|
||||
lines: [...mockLogsResult, ...mockLogsResult],
|
||||
isLoading: false,
|
||||
cursor: mockNextCursor,
|
||||
isComplete: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('receives logs lines and a null cursor to indicate is complete', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_PREPEND_SUCCESS](state, {
|
||||
logs: mockLogsResult,
|
||||
cursor: null,
|
||||
});
|
||||
|
||||
expect(state.logs).toEqual({
|
||||
lines: mockLogsResult,
|
||||
isLoading: false,
|
||||
cursor: null,
|
||||
isComplete: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECEIVE_LOGS_DATA_PREPEND_ERROR', () => {
|
||||
it('receives logs lines and cursor', () => {
|
||||
mutations[types.RECEIVE_LOGS_DATA_PREPEND_ERROR](state);
|
||||
|
||||
expect(state.logs.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -121,6 +207,7 @@ describe('Logs Store Mutations', () => {
|
|||
|
||||
describe('SET_TIME_RANGE', () => {
|
||||
it('sets a default range', () => {
|
||||
expect(state.timeRange.selected).toEqual(expect.any(Object));
|
||||
expect(state.timeRange.current).toEqual(expect.any(Object));
|
||||
});
|
||||
|
||||
|
@ -131,12 +218,13 @@ describe('Logs Store Mutations', () => {
|
|||
};
|
||||
mutations[types.SET_TIME_RANGE](state, mockRange);
|
||||
|
||||
expect(state.timeRange.selected).toEqual(mockRange);
|
||||
expect(state.timeRange.current).toEqual(mockRange);
|
||||
});
|
||||
});
|
||||
|
||||
describe('REQUEST_PODS_DATA', () => {
|
||||
it('receives log data error and stops loading', () => {
|
||||
it('receives pods data', () => {
|
||||
mutations[types.REQUEST_PODS_DATA](state);
|
||||
|
||||
expect(state.pods).toEqual(
|
||||
|
|
|
@ -54,10 +54,10 @@ describe('Changed file icon', () => {
|
|||
});
|
||||
|
||||
describe.each`
|
||||
file | iconName | tooltipText | desc
|
||||
${changedFile()} | ${'file-modified'} | ${'Unstaged modification'} | ${'with file changed'}
|
||||
${stagedFile()} | ${'file-modified-solid'} | ${'Staged modification'} | ${'with file staged'}
|
||||
${newFile()} | ${'file-addition'} | ${'Unstaged addition'} | ${'with file new'}
|
||||
file | iconName | tooltipText | desc
|
||||
${changedFile()} | ${'file-modified'} | ${'Modified'} | ${'with file changed'}
|
||||
${stagedFile()} | ${'file-modified-solid'} | ${'Modified'} | ${'with file staged'}
|
||||
${newFile()} | ${'file-addition'} | ${'Added'} | ${'with file new'}
|
||||
`('$desc', ({ file, iconName, tooltipText }) => {
|
||||
beforeEach(() => {
|
||||
factory({ file });
|
||||
|
|
|
@ -11,12 +11,12 @@ describe Mutations::ResolvesGroup do
|
|||
|
||||
let(:context) { double }
|
||||
|
||||
subject(:mutation) { mutation_class.new(object: nil, context: context) }
|
||||
subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
|
||||
|
||||
it 'uses the GroupsResolver to resolve groups by path' do
|
||||
group = create(:group)
|
||||
|
||||
expect(Resolvers::GroupResolver).to receive(:new).with(object: nil, context: context).and_call_original
|
||||
expect(Resolvers::GroupResolver).to receive(:new).with(object: nil, context: context, field: nil).and_call_original
|
||||
expect(mutation.resolve_group(full_path: group.full_path).sync).to eq(group)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,7 +12,7 @@ describe Mutations::ResolvesIssuable do
|
|||
let(:project) { create(:project) }
|
||||
let(:user) { create(:user) }
|
||||
let(:context) { { current_user: user } }
|
||||
let(:mutation) { mutation_class.new(object: nil, context: context) }
|
||||
let(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
|
||||
|
||||
shared_examples 'resolving an issuable' do |type|
|
||||
context 'when user has access' do
|
||||
|
@ -39,7 +39,7 @@ describe Mutations::ResolvesIssuable do
|
|||
.and_return(resolved_project)
|
||||
|
||||
expect(resolver_class).to receive(:new)
|
||||
.with(object: resolved_project, context: context)
|
||||
.with(object: resolved_project, context: context, field: nil)
|
||||
.and_call_original
|
||||
|
||||
subject
|
||||
|
@ -47,7 +47,7 @@ describe Mutations::ResolvesIssuable do
|
|||
|
||||
it 'uses the ResolvesProject to resolve project' do
|
||||
expect(Resolvers::ProjectResolver).to receive(:new)
|
||||
.with(object: nil, context: context)
|
||||
.with(object: nil, context: context, field: nil)
|
||||
.and_call_original
|
||||
|
||||
subject
|
||||
|
|
|
@ -11,12 +11,12 @@ describe Mutations::ResolvesProject do
|
|||
|
||||
let(:context) { double }
|
||||
|
||||
subject(:mutation) { mutation_class.new(object: nil, context: context) }
|
||||
subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
|
||||
|
||||
it 'uses the ProjectsResolver to resolve projects by path' do
|
||||
project = create(:project)
|
||||
|
||||
expect(Resolvers::ProjectResolver).to receive(:new).with(object: nil, context: context).and_call_original
|
||||
expect(Resolvers::ProjectResolver).to receive(:new).with(object: nil, context: context, field: nil).and_call_original
|
||||
expect(mutation.resolve_project(full_path: project.full_path).sync).to eq(project)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,7 +6,7 @@ describe Mutations::Issues::SetConfidential do
|
|||
let(:issue) { create(:issue) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:confidential) { true }
|
||||
|
|
|
@ -6,7 +6,7 @@ describe Mutations::Issues::SetDueDate do
|
|||
let(:issue) { create(:issue) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:due_date) { 2.days.since }
|
||||
|
|
|
@ -13,7 +13,7 @@ describe Mutations::Issues::Update do
|
|||
due_date: Date.tomorrow
|
||||
}
|
||||
end
|
||||
let(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
let(:mutated_issue) { subject[:issue] }
|
||||
|
||||
describe '#resolve' do
|
||||
|
|
|
@ -6,7 +6,7 @@ describe Mutations::MergeRequests::SetAssignees do
|
|||
let(:merge_request) { create(:merge_request) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:assignee) { create(:user) }
|
||||
|
|
|
@ -6,7 +6,7 @@ describe Mutations::MergeRequests::SetLabels do
|
|||
let(:merge_request) { create(:merge_request) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:label) { create(:label, project: merge_request.project) }
|
||||
|
|
|
@ -6,7 +6,7 @@ describe Mutations::MergeRequests::SetLocked do
|
|||
let(:merge_request) { create(:merge_request) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:locked) { true }
|
||||
|
|
|
@ -6,7 +6,7 @@ describe Mutations::MergeRequests::SetMilestone do
|
|||
let(:merge_request) { create(:merge_request) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:milestone) { create(:milestone, project: merge_request.project) }
|
||||
|
|
|
@ -7,7 +7,7 @@ describe Mutations::MergeRequests::SetSubscription do
|
|||
let(:project) { merge_request.project }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:subscribe) { true }
|
||||
|
|
|
@ -6,7 +6,7 @@ describe Mutations::MergeRequests::SetWip do
|
|||
let(:merge_request) { create(:merge_request) }
|
||||
let(:user) { create(:user) }
|
||||
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }) }
|
||||
subject(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
|
||||
|
||||
describe '#resolve' do
|
||||
let(:wip) { true }
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue