Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-03-13 12:09:22 +00:00
parent 4cb5e5011a
commit 286fe61013
121 changed files with 23623 additions and 22031 deletions

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View file

@ -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;

View file

@ -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" />

View file

@ -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>

View file

@ -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>

View file

@ -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 () => {};

View file

@ -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 () => {};

View file

@ -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';

View file

@ -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;
},

View file

@ -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,
},
/**

View file

@ -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 {};

View file

@ -0,0 +1 @@
import '~/pages/sessions/index';

View file

@ -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

View file

@ -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,
}),
});
};

View file

@ -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 (

View file

@ -257,7 +257,6 @@
width: 15px;
height: 15px;
display: $svg-display;
fill: $gl-text-color;
top: $svg-top;
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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)

View 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

View file

@ -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' }

View file

@ -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

View 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'

View 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"

View file

@ -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?

View 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'

View file

@ -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

View 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'

View file

@ -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"

View file

@ -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'

View file

@ -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.")

View file

@ -856,7 +856,7 @@
:urgency: :high
:resource_boundary: :unknown
:weight: 2
:idempotent:
:idempotent: true
- :name: background_migration
:feature_category: :not_owned
:has_external_dependencies:

View file

@ -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.

View file

@ -0,0 +1,5 @@
---
title: Remove staging from commit workflow in the Web IDE
merge_request: 26151
author:
type: removed

View file

@ -0,0 +1,5 @@
---
title: More logs entries are loaded when logs are scrolled to the top
merge_request: 26254
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Optimize notes counters in usage data
merge_request: 26871
author:
type: performance

View file

@ -0,0 +1,5 @@
---
title: Backfill LfsObjectsProject records of forks
merge_request: 26964
author:
type: other

View file

@ -0,0 +1,5 @@
---
title: Add 2FA support to admin mode feature
merge_request: 22281
author: Diego Louzán
type: added

View file

@ -0,0 +1,5 @@
---
title: Add "New release" button to Releases page
merge_request: 24516
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Update Ruby version in official CI templates
merge_request: 23585
author: Takuya Noguchi
type: other

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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`

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -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**

View file

@ -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

View file

@ -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:**

View file

@ -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).

View file

@ -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.

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 KiB

View file

@ -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.

View 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

View file

@ -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:

View file

@ -1,5 +1,5 @@
# Full project: https://gitlab.com/pages/middleman
image: ruby:2.3
image: ruby:2.6
cache:
paths:

View file

@ -1,5 +1,5 @@
# Full project: https://gitlab.com/pages/nanoc
image: ruby:2.3
image: ruby:2.6
pages:
script:

View file

@ -1,5 +1,5 @@
# Full project: https://gitlab.com/pages/octopress
image: ruby:2.3
image: ruby:2.6
pages:
script:

View file

@ -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."

View file

@ -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

View 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

View 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

View 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

View file

@ -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'

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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';

View file

@ -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);
},
);
});

View file

@ -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(

View file

@ -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 });

View 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

View file

@ -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

View file

@ -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

View file

@ -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 }

View file

@ -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 }

View file

@ -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

View file

@ -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) }

View file

@ -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) }

View file

@ -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 }

View file

@ -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) }

View file

@ -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 }

View file

@ -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