diff options
Diffstat (limited to 'app')
45 files changed, 615 insertions, 312 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index c85e5b68f5f..dc6ea148047 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -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; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 3398cd091ba..e618fb3daae 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 5ec3fc4041b..f6ca728defc 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index d9a385a9d31..2e273d45506 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -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> @@ -96,24 +79,6 @@ export default { <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 :title="__('Discard all changes')" @@ -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> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 726e2b7e1fc..e49d96efe50 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -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"> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 3ef7d863bd5..32822a75772 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -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; diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 62fb0b03975..b8dca2709c8 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -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" /> diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index b94cd2bcec4..b0acd69bae0 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -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> diff --git a/app/assets/javascripts/logs/components/log_control_buttons.vue b/app/assets/javascripts/logs/components/log_control_buttons.vue index d55c2f7cd4c..170d0474447 100644 --- a/app/assets/javascripts/logs/components/log_control_buttons.vue +++ b/app/assets/javascripts/logs/components/log_control_buttons.vue @@ -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> diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index 89a896b9dec..4544ebdfec1 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -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 () => {}; diff --git a/app/assets/javascripts/logs/stores/getters.js b/app/assets/javascripts/logs/stores/getters.js index c7dbb72ce3d..58f2dbf4835 100644 --- a/app/assets/javascripts/logs/stores/getters.js +++ b/app/assets/javascripts/logs/stores/getters.js @@ -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 () => {}; diff --git a/app/assets/javascripts/logs/stores/mutation_types.js b/app/assets/javascripts/logs/stores/mutation_types.js index b8e70f95d92..5ff49135e41 100644 --- a/app/assets/javascripts/logs/stores/mutation_types.js +++ b/app/assets/javascripts/logs/stores/mutation_types.js @@ -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'; diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js index ca31dd3bc20..d94d71cd25a 100644 --- a/app/assets/javascripts/logs/stores/mutations.js +++ b/app/assets/javascripts/logs/stores/mutations.js @@ -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; }, diff --git a/app/assets/javascripts/logs/stores/state.js b/app/assets/javascripts/logs/stores/state.js index eaf1b1bdd93..e058f15b6b4 100644 --- a/app/assets/javascripts/logs/stores/state.js +++ b/app/assets/javascripts/logs/stores/state.js @@ -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, }, /** diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js index 668efee74e8..30213dbc130 100644 --- a/app/assets/javascripts/logs/utils.js +++ b/app/assets/javascripts/logs/utils.js @@ -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 {}; diff --git a/app/assets/javascripts/pages/admin/sessions/index.js b/app/assets/javascripts/pages/admin/sessions/index.js new file mode 100644 index 00000000000..680ebd19a9f --- /dev/null +++ b/app/assets/javascripts/pages/admin/sessions/index.js @@ -0,0 +1 @@ +import '~/pages/sessions/index'; diff --git a/app/assets/javascripts/releases/components/app_index.vue b/app/assets/javascripts/releases/components/app_index.vue index b9e80899e25..511b3cda9c8 100644 --- a/app/assets/javascripts/releases/components/app_index.vue +++ b/app/assets/javascripts/releases/components/app_index.vue @@ -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 diff --git a/app/assets/javascripts/releases/mount_index.js b/app/assets/javascripts/releases/mount_index.js index ad82d9a65d6..5f0bf3b6459 100644 --- a/app/assets/javascripts/releases/mount_index.js +++ b/app/assets/javascripts/releases/mount_index.js @@ -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, }), }); }; diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 9ec99ac93d7..7acbe949151 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -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 ( diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index d54648cc34b..fd448ee24ed 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -257,7 +257,6 @@ width: 15px; height: 15px; display: $svg-display; - fill: $gl-text-color; top: $svg-top; } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 59266af96b4..c829695621c 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -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; + } } diff --git a/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb b/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb new file mode 100644 index 00000000000..c6fd1d55e51 --- /dev/null +++ b/app/controllers/admin/concerns/authenticates_2fa_for_admin_mode.rb @@ -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 diff --git a/app/controllers/admin/sessions_controller.rb b/app/controllers/admin/sessions_controller.rb index f9587655a8d..841ad46b47e 100644 --- a/app/controllers/admin/sessions_controller.rb +++ b/app/controllers/admin/sessions_controller.rb @@ -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 diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index 6f0c7abac16..b885e55f902 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -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 diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index bc3308fd6c6..d82a46e57ea 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -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 diff --git a/app/controllers/projects/import/jira_controller.rb b/app/controllers/projects/import/jira_controller.rb new file mode 100644 index 00000000000..c74c180fa20 --- /dev/null +++ b/app/controllers/projects/import/jira_controller.rb @@ -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 diff --git a/app/graphql/mutations/concerns/mutations/resolves_group.rb b/app/graphql/mutations/concerns/mutations/resolves_group.rb index d5a040c84e9..11d7b34217d 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_group.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_group.rb @@ -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 diff --git a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb index 4146bf8fdc8..3a4db5ae18d 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_issuable.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_issuable.rb @@ -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) diff --git a/app/graphql/mutations/concerns/mutations/resolves_project.rb b/app/graphql/mutations/concerns/mutations/resolves_project.rb index 0e91a25b803..e223e3edd94 100644 --- a/app/graphql/mutations/concerns/mutations/resolves_project.rb +++ b/app/graphql/mutations/concerns/mutations/resolves_project.rb @@ -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 diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index c4fe40a0875..6fbef800faa 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -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 diff --git a/app/models/concerns/bulk_insert_safe.rb b/app/models/concerns/bulk_insert_safe.rb index d8587ea78ec..a4814fc0d48 100644 --- a/app/models/concerns/bulk_insert_safe.rb +++ b/app/models/concerns/bulk_insert_safe.rb @@ -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) diff --git a/app/models/jira_import_data.rb b/app/models/jira_import_data.rb new file mode 100644 index 00000000000..3f882deb24d --- /dev/null +++ b/app/models/jira_import_data.rb @@ -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 diff --git a/app/views/admin/sessions/_new_base.html.haml b/app/views/admin/sessions/_new_base.html.haml index 50fa48855c0..a8d678d2b61 100644 --- a/app/views/admin/sessions/_new_base.html.haml +++ b/app/views/admin/sessions/_new_base.html.haml @@ -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' } diff --git a/app/views/admin/sessions/_tabs_normal.html.haml b/app/views/admin/sessions/_tabs_normal.html.haml index 20830051d31..2e279013720 100644 --- a/app/views/admin/sessions/_tabs_normal.html.haml +++ b/app/views/admin/sessions/_tabs_normal.html.haml @@ -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 diff --git a/app/views/admin/sessions/_two_factor_otp.html.haml b/app/views/admin/sessions/_two_factor_otp.html.haml new file mode 100644 index 00000000000..9d4acbf1b99 --- /dev/null +++ b/app/views/admin/sessions/_two_factor_otp.html.haml @@ -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' diff --git a/app/views/admin/sessions/_two_factor_u2f.html.haml b/app/views/admin/sessions/_two_factor_u2f.html.haml new file mode 100644 index 00000000000..09b91d76295 --- /dev/null +++ b/app/views/admin/sessions/_two_factor_u2f.html.haml @@ -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" diff --git a/app/views/admin/sessions/new.html.haml b/app/views/admin/sessions/new.html.haml index a1d440f2cfd..0a7f20b861e 100644 --- a/app/views/admin/sessions/new.html.haml +++ b/app/views/admin/sessions/new.html.haml @@ -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? diff --git a/app/views/admin/sessions/two_factor.html.haml b/app/views/admin/sessions/two_factor.html.haml new file mode 100644 index 00000000000..3a0cbe3facb --- /dev/null +++ b/app/views/admin/sessions/two_factor.html.haml @@ -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' diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index 1b583ea85d6..cca0f756e76 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -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 diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml new file mode 100644 index 00000000000..f295a241113 --- /dev/null +++ b/app/views/projects/import/jira/show.html.haml @@ -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' diff --git a/app/views/projects/issues/import_csv/_button.html.haml b/app/views/projects/issues/import_csv/_button.html.haml index fe89d2fb748..78c561e81ef 100644 --- a/app/views/projects/issues/import_csv/_button.html.haml +++ b/app/views/projects/issues/import_csv/_button.html.haml @@ -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" diff --git a/app/views/projects/tags/new.html.haml b/app/views/projects/tags/new.html.haml index a7f739ab13d..1b3b0972744 100644 --- a/app/views/projects/tags/new.html.haml +++ b/app/views/projects/tags/new.html.haml @@ -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' diff --git a/app/views/u2f/_authenticate.html.haml b/app/views/u2f/_authenticate.html.haml index 979f6862de3..51018428b1b 100644 --- a/app/views/u2f/_authenticate.html.haml +++ b/app/views/u2f/_authenticate.html.haml @@ -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.") diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 545e8886d61..49dcc441780 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -856,7 +856,7 @@ :urgency: :high :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: true - :name: background_migration :feature_category: :not_owned :has_external_dependencies: diff --git a/app/workers/authorized_projects_worker.rb b/app/workers/authorized_projects_worker.rb index 17537cdaa26..a35e0320553 100644 --- a/app/workers/authorized_projects_worker.rb +++ b/app/workers/authorized_projects_worker.rb @@ -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. |