Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-05-29 18:08:26 +00:00
parent 6e33325c14
commit b64a8161c9
227 changed files with 7073 additions and 645 deletions

View File

@ -1,5 +1,4 @@
<script>
import Vue from 'vue';
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
@ -27,15 +26,9 @@ export default {
CommitEditorHeader,
GlDeprecatedButton,
GlLoadingIcon,
RightPane,
},
mixins: [glFeatureFlagsMixin()],
props: {
rightPaneComponent: {
type: Vue.Component,
required: false,
default: () => RightPane,
},
},
computed: {
...mapState([
'openFiles',
@ -151,7 +144,7 @@ export default {
</div>
</template>
</div>
<component :is="rightPaneComponent" v-if="currentProjectId" />
<right-pane v-if="currentProjectId" />
</div>
<ide-status-bar />
<new-modal ref="newModal" />

View File

@ -1,7 +1,7 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { mapActions, mapState, mapGetters } from 'vuex';
import IdeStatusList from 'ee_else_ce/ide/components/ide_status_list.vue';
import IdeStatusList from './ide_status_list.vue';
import IdeStatusMr from './ide_status_mr.vue';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';

View File

@ -1,7 +1,11 @@
<script>
import { mapGetters } from 'vuex';
import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue';
export default {
components: {
TerminalSyncStatusSafe,
},
computed: {
...mapGetters(['activeFile']),
},
@ -18,6 +22,6 @@ export default {
</div>
<div class="ide-status-file">{{ activeFile.fileLanguage }}</div>
</template>
<slot></slot>
<terminal-sync-status-safe />
</div>
</template>

View File

@ -7,6 +7,7 @@ import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../
import PipelinesList from '../pipelines/list.vue';
import JobsDetail from '../jobs/detail.vue';
import Clientside from '../preview/clientside.vue';
import TerminalView from '../terminal/view.vue';
// Need to add the width of the nav buttons since the resizable container contains those as well
const WIDTH = SIDEBAR_INIT_WIDTH + SIDEBAR_NAV_WIDTH;
@ -17,14 +18,8 @@ export default {
CollapsibleSidebar,
ResizablePanel,
},
props: {
extensionTabs: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
...mapState('terminal', { isTerminalVisible: 'isVisible' }),
...mapState(['currentMergeRequestId', 'clientsidePreviewEnabled']),
...mapGetters(['packageJson']),
...mapState('rightPane', ['isOpen']),
@ -48,7 +43,12 @@ export default {
views: [{ component: Clientside, ...rightSidebarViews.clientSidePreview }],
icon: 'live-preview',
},
...this.extensionTabs,
{
show: this.isTerminalVisible,
title: __('Terminal'),
views: [{ component: TerminalView, ...rightSidebarViews.terminal }],
icon: 'terminal',
},
];
},
},

View File

@ -0,0 +1,71 @@
<script>
import { GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
GlLoadingIcon,
},
props: {
isLoading: {
type: Boolean,
required: false,
default: true,
},
isValid: {
type: Boolean,
required: false,
default: false,
},
message: {
type: String,
required: false,
default: '',
},
helpPath: {
type: String,
required: false,
default: '',
},
illustrationPath: {
type: String,
required: false,
default: '',
},
},
methods: {
onStart() {
this.$emit('start');
},
},
};
</script>
<template>
<div class="text-center p-3">
<div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div>
<h4>{{ __('Web Terminal') }}</h4>
<gl-loading-icon v-if="isLoading" size="lg" class="prepend-top-default" />
<template v-else>
<p>{{ __('Run tests against your code live using the Web Terminal') }}</p>
<p>
<button
:disabled="!isValid"
class="btn btn-info"
type="button"
data-qa-selector="start_web_terminal_button"
@click="onStart"
>
{{ __('Start Web Terminal') }}
</button>
</p>
<div v-if="!isValid && message" class="bs-callout text-left" v-html="message"></div>
<p v-else>
<a
v-if="helpPath"
:href="helpPath"
target="_blank"
v-text="__('Learn more about Web Terminal')"
></a>
</p>
</template>
</div>
</template>

View File

@ -0,0 +1,53 @@
<script>
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import Terminal from './terminal.vue';
import { isEndingStatus } from '../../stores/modules/terminal/utils';
export default {
components: {
Terminal,
},
computed: {
...mapState('terminal', ['session']),
actionButton() {
if (isEndingStatus(this.session.status)) {
return {
action: () => this.restartSession(),
text: __('Restart Terminal'),
class: 'btn-primary',
};
}
return {
action: () => this.stopSession(),
text: __('Stop Terminal'),
class: 'btn-inverted btn-remove',
};
},
},
methods: {
...mapActions('terminal', ['restartSession', 'stopSession']),
},
};
</script>
<template>
<div v-if="session" class="ide-terminal d-flex flex-column">
<header class="ide-job-header d-flex align-items-center">
<h5>{{ __('Web Terminal') }}</h5>
<div class="ml-auto align-self-center">
<button
v-if="actionButton"
type="button"
class="btn btn-sm"
:class="actionButton.class"
@click="actionButton.action"
>
{{ actionButton.text }}
</button>
</div>
</header>
<terminal :terminal-path="session.terminalPath" :status="session.status" />
</div>
</template>

View File

@ -0,0 +1,117 @@
<script>
import { mapState } from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import GLTerminal from '~/terminal/terminal';
import TerminalControls from './terminal_controls.vue';
import { RUNNING, STOPPING } from '../../stores/modules/terminal/constants';
import { isStartingStatus } from '../../stores/modules/terminal/utils';
export default {
components: {
GlLoadingIcon,
TerminalControls,
},
props: {
terminalPath: {
type: String,
required: false,
default: '',
},
status: {
type: String,
required: true,
},
},
data() {
return {
glterminal: null,
canScrollUp: false,
canScrollDown: false,
};
},
computed: {
...mapState(['panelResizing']),
loadingText() {
if (isStartingStatus(this.status)) {
return __('Starting...');
} else if (this.status === STOPPING) {
return __('Stopping...');
}
return '';
},
},
watch: {
panelResizing() {
if (!this.panelResizing && this.glterminal) {
this.glterminal.fit();
}
},
status() {
this.refresh();
},
terminalPath() {
this.refresh();
},
},
beforeDestroy() {
this.destroyTerminal();
},
methods: {
refresh() {
if (this.status === RUNNING && this.terminalPath) {
this.createTerminal();
} else if (this.status === STOPPING) {
this.stopTerminal();
}
},
createTerminal() {
this.destroyTerminal();
this.glterminal = new GLTerminal(this.$refs.terminal);
this.glterminal.addScrollListener(({ canScrollUp, canScrollDown }) => {
this.canScrollUp = canScrollUp;
this.canScrollDown = canScrollDown;
});
},
destroyTerminal() {
if (this.glterminal) {
this.glterminal.dispose();
this.glterminal = null;
}
},
stopTerminal() {
if (this.glterminal) {
this.glterminal.disable();
}
},
},
};
</script>
<template>
<div class="d-flex flex-column flex-fill min-height-0 pr-3">
<div class="top-bar d-flex border-left-0 align-items-center">
<div v-if="loadingText" data-qa-selector="loading_container">
<gl-loading-icon :inline="true" />
<span>{{ loadingText }}</span>
</div>
<terminal-controls
v-if="glterminal"
class="ml-auto"
:can-scroll-up="canScrollUp"
:can-scroll-down="canScrollDown"
@scroll-up="glterminal.scrollToTop()"
@scroll-down="glterminal.scrollToBottom()"
/>
</div>
<div class="terminal-wrapper d-flex flex-fill min-height-0">
<div
ref="terminal"
class="ide-terminal-trace flex-fill min-height-0 w-100"
:data-project-path="terminalPath"
data-qa-selector="terminal_screen"
></div>
</div>
</div>
</template>

View File

@ -0,0 +1,27 @@
<script>
import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue';
export default {
components: {
ScrollButton,
},
props: {
canScrollUp: {
type: Boolean,
required: false,
default: false,
},
canScrollDown: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div class="controllers">
<scroll-button :disabled="!canScrollUp" direction="up" @click="$emit('scroll-up')" />
<scroll-button :disabled="!canScrollDown" direction="down" @click="$emit('scroll-down')" />
</div>
</template>

View File

@ -0,0 +1,41 @@
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import EmptyState from './empty_state.vue';
import TerminalSession from './session.vue';
export default {
components: {
EmptyState,
TerminalSession,
},
computed: {
...mapState('terminal', ['isShowSplash', 'paths']),
...mapGetters('terminal', ['allCheck']),
},
methods: {
...mapActions('terminal', ['startSession', 'hideSplash']),
start() {
this.startSession();
this.hideSplash();
},
},
};
</script>
<template>
<div class="h-100">
<div v-if="isShowSplash" class="h-100 d-flex flex-column justify-content-center">
<empty-state
:is-loading="allCheck.isLoading"
:is-valid="allCheck.isValid"
:message="allCheck.message"
:help-path="paths.webTerminalHelpPath"
:illustration-path="paths.webTerminalSvgPath"
@start="start()"
/>
</div>
<template v-else>
<terminal-session />
</template>
</div>
</template>

View File

@ -0,0 +1,76 @@
<script>
import { throttle } from 'lodash';
import { GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import { mapState } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import {
MSG_TERMINAL_SYNC_CONNECTING,
MSG_TERMINAL_SYNC_UPLOADING,
MSG_TERMINAL_SYNC_RUNNING,
} from '../../stores/modules/terminal_sync/messages';
export default {
components: {
Icon,
GlLoadingIcon,
},
directives: {
'gl-tooltip': GlTooltipDirective,
},
data() {
return { isLoading: false };
},
computed: {
...mapState('terminalSync', ['isError', 'isStarted', 'message']),
...mapState('terminalSync', {
isLoadingState: 'isLoading',
}),
status() {
if (this.isLoading) {
return {
icon: '',
text: this.isStarted ? MSG_TERMINAL_SYNC_UPLOADING : MSG_TERMINAL_SYNC_CONNECTING,
};
} else if (this.isError) {
return {
icon: 'warning',
text: this.message,
};
} else if (this.isStarted) {
return {
icon: 'mobile-issue-close',
text: MSG_TERMINAL_SYNC_RUNNING,
};
}
return null;
},
},
watch: {
// We want to throttle the `isLoading` updates so that
// the user actually sees an indicator that changes are sent.
isLoadingState: throttle(function watchIsLoadingState(val) {
this.isLoading = val;
}, 150),
},
created() {
this.isLoading = this.isLoadingState;
},
};
</script>
<template>
<div
v-if="status"
v-gl-tooltip
:title="status.text"
role="note"
class="d-flex align-items-center"
>
<span>{{ __('Terminal') }}:</span>
<span class="square s16 d-flex-center ml-1" :aria-label="status.text">
<gl-loading-icon v-if="isLoading" inline size="sm" class="d-flex-center" />
<icon v-else-if="status.icon" :name="status.icon" :size="16" />
</span>
</div>
</template>

View File

@ -0,0 +1,22 @@
<script>
import { mapState } from 'vuex';
import TerminalSyncStatus from './terminal_sync_status.vue';
/**
* It is possible that the vuex module is not registered.
*
* This component will gracefully handle this so the actual one can simply use `mapState(moduleName, ...)`.
*/
export default {
components: {
TerminalSyncStatus,
},
computed: {
...mapState(['terminalSync']),
},
};
</script>
<template>
<terminal-sync-status v-if="terminalSync" />
</template>

View File

@ -57,6 +57,7 @@ export const rightSidebarViews = {
jobsDetail: { name: 'jobs-detail', keepAlive: false },
mergeRequestInfo: { name: 'merge-request-info', keepAlive: true },
clientSidePreview: { name: 'clientside', keepAlive: false },
terminal: { name: 'terminal', keepAlive: true },
};
export const stageKeys = {

View File

@ -0,0 +1,85 @@
import { commitActionForFile } from '~/ide/stores/utils';
import { commitActionTypes } from '~/ide/constants';
import createFileDiff from './create_file_diff';
const getDeletedParents = (entries, file) => {
const parent = file.parentPath && entries[file.parentPath];
if (parent && parent.deleted) {
return [parent, ...getDeletedParents(entries, parent)];
}
return [];
};
const filesWithChanges = ({ stagedFiles = [], changedFiles = [], entries = {} }) => {
// We need changed files to overwrite staged, so put them at the end.
const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => {
const key = file.path;
const action = commitActionForFile(file);
const prev = acc[key];
// If a file was deleted, which was previously added, then we should do nothing.
if (action === commitActionTypes.delete && prev && prev.action === commitActionTypes.create) {
delete acc[key];
} else {
acc[key] = { action, file };
}
return acc;
}, {});
// We need to clean "move" actions, because we can only support 100% similarity moves at the moment.
// This is because the previous file's content might not be loaded.
Object.values(changes)
.filter(change => change.action === commitActionTypes.move)
.forEach(change => {
const prev = changes[change.file.prevPath];
if (!prev) {
return;
}
if (change.file.content === prev.file.content) {
// If content is the same, continue with the move but don't do the prevPath's delete.
delete changes[change.file.prevPath];
} else {
// Otherwise, treat the move as a delete / create.
Object.assign(change, { action: commitActionTypes.create });
}
});
// Next, we need to add deleted directories by looking at the parents
Object.values(changes)
.filter(change => change.action === commitActionTypes.delete && change.file.parentPath)
.forEach(({ file }) => {
// Do nothing if we've already visited this directory.
if (changes[file.parentPath]) {
return;
}
getDeletedParents(entries, file).forEach(parent => {
changes[parent.path] = { action: commitActionTypes.delete, file: parent };
});
});
return Object.values(changes);
};
const createDiff = state => {
const changes = filesWithChanges(state);
const toDelete = changes.filter(x => x.action === commitActionTypes.delete).map(x => x.file.path);
const patch = changes
.filter(x => x.action !== commitActionTypes.delete)
.map(({ file, action }) => createFileDiff(file, action))
.join('');
return {
patch,
toDelete,
};
};
export default createDiff;

View File

@ -0,0 +1,112 @@
/* eslint-disable @gitlab/require-i18n-strings */
import { createTwoFilesPatch } from 'diff';
import { commitActionTypes } from '~/ide/constants';
const DEV_NULL = '/dev/null';
const DEFAULT_MODE = '100644';
const NO_NEW_LINE = '\\ No newline at end of file';
const NEW_LINE = '\n';
/**
* Cleans patch generated by `diff` package.
*
* - Removes "=======" separator added at the beginning
*/
const cleanTwoFilesPatch = text => text.replace(/^(=+\s*)/, '');
const endsWithNewLine = val => !val || val[val.length - 1] === NEW_LINE;
const addEndingNewLine = val => (endsWithNewLine(val) ? val : val + NEW_LINE);
const removeEndingNewLine = val => (endsWithNewLine(val) ? val.substr(0, val.length - 1) : val);
const diffHead = (prevPath, newPath = '') =>
`diff --git "a/${prevPath}" "b/${newPath || prevPath}"`;
const createDiffBody = (path, content, isCreate) => {
if (!content) {
return '';
}
const prefix = isCreate ? '+' : '-';
const fromPath = isCreate ? DEV_NULL : `a/${path}`;
const toPath = isCreate ? `b/${path}` : DEV_NULL;
const hasNewLine = endsWithNewLine(content);
const lines = removeEndingNewLine(content).split(NEW_LINE);
const chunkHead = isCreate ? `@@ -0,0 +1,${lines.length} @@` : `@@ -1,${lines.length} +0,0 @@`;
const chunk = lines
.map(line => `${prefix}${line}`)
.concat(!hasNewLine ? [NO_NEW_LINE] : [])
.join(NEW_LINE);
return `--- ${fromPath}
+++ ${toPath}
${chunkHead}
${chunk}`;
};
const createMoveFileDiff = (prevPath, newPath) => `${diffHead(prevPath, newPath)}
rename from ${prevPath}
rename to ${newPath}`;
const createNewFileDiff = (path, content) => {
const diff = createDiffBody(path, content, true);
return `${diffHead(path)}
new file mode ${DEFAULT_MODE}
${diff}`;
};
const createDeleteFileDiff = (path, content) => {
const diff = createDiffBody(path, content, false);
return `${diffHead(path)}
deleted file mode ${DEFAULT_MODE}
${diff}`;
};
const createUpdateFileDiff = (path, oldContent, newContent) => {
const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent);
return `${diffHead(path)}
${cleanTwoFilesPatch(patch)}`;
};
const createFileDiffRaw = (file, action) => {
switch (action) {
case commitActionTypes.move:
return createMoveFileDiff(file.prevPath, file.path);
case commitActionTypes.create:
return createNewFileDiff(file.path, file.content);
case commitActionTypes.delete:
return createDeleteFileDiff(file.path, file.content);
case commitActionTypes.update:
return createUpdateFileDiff(file.path, file.raw || '', file.content);
default:
return '';
}
};
/**
* Create a git diff for a single IDE file.
*
* ## Notes:
* When called with `commitActionType.move`, it assumes that the move
* is a 100% similarity move. No diff will be generated. This is because
* generating a move with changes is not support by the current IDE, since
* the source file might not have it's content loaded yet.
*
* When called with `commitActionType.delete`, it does not support
* deleting files with a mode different than 100644. For the IDE mirror, this
* isn't needed because deleting is handled outside the unified patch.
*
* ## References:
* - https://git-scm.com/docs/git-diff#_generating_patches_with_p
*/
const createFileDiff = (file, action) =>
// It's important that the file diff ends in a new line - git expects this.
addEndingNewLine(createFileDiffRaw(file, action));
export default createFileDiff;

View File

@ -0,0 +1,154 @@
import createDiff from './create_diff';
import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
export const SERVICE_NAME = 'webide-file-sync';
export const PROTOCOL = 'webfilesync.gitlab.com';
export const MSG_CONNECTION_ERROR = __('Could not connect to Web IDE file mirror service.');
// Before actually connecting to the service, we must delay a bit
// so that the service has sufficiently started.
const noop = () => {};
export const SERVICE_DELAY = 8000;
const cancellableWait = time => {
let timeoutId = 0;
const cancel = () => clearTimeout(timeoutId);
const promise = new Promise(resolve => {
timeoutId = setTimeout(resolve, time);
});
return [promise, cancel];
};
const isErrorResponse = error => error && error.code !== 0;
const isErrorPayload = payload => payload && payload.status_code !== 200;
const getErrorFromResponse = data => {
if (isErrorResponse(data.error)) {
return { message: data.error.Message };
} else if (isErrorPayload(data.payload)) {
return { message: data.payload.error_message };
}
return null;
};
const getFullPath = path => mergeUrlParams({ service: SERVICE_NAME }, getWebSocketUrl(path));
const createWebSocket = fullPath =>
new Promise((resolve, reject) => {
const socket = new WebSocket(fullPath, [PROTOCOL]);
const resetCallbacks = () => {
socket.onopen = null;
socket.onerror = null;
};
socket.onopen = () => {
resetCallbacks();
resolve(socket);
};
socket.onerror = () => {
resetCallbacks();
reject(new Error(MSG_CONNECTION_ERROR));
};
});
export const canConnect = ({ services = [] }) => services.some(name => name === SERVICE_NAME);
export const createMirror = () => {
let socket = null;
let cancelHandler = noop;
let nextMessageHandler = noop;
const cancelConnect = () => {
cancelHandler();
cancelHandler = noop;
};
const onCancelConnect = fn => {
cancelHandler = fn;
};
const receiveMessage = ev => {
const handle = nextMessageHandler;
nextMessageHandler = noop;
handle(JSON.parse(ev.data));
};
const onNextMessage = fn => {
nextMessageHandler = fn;
};
const waitForNextMessage = () =>
new Promise((resolve, reject) => {
onNextMessage(data => {
const err = getErrorFromResponse(data);
if (err) {
reject(err);
} else {
resolve();
}
});
});
const uploadDiff = ({ toDelete, patch }) => {
if (!socket) {
return Promise.resolve();
}
const response = waitForNextMessage();
const msg = {
code: 'EVENT',
namespace: '/files',
event: 'PATCH',
payload: { diff: patch, delete_files: toDelete },
};
socket.send(JSON.stringify(msg));
return response;
};
return {
upload(state) {
return uploadDiff(createDiff(state));
},
connect(path) {
if (socket) {
this.disconnect();
}
const fullPath = getFullPath(path);
const [wait, cancelWait] = cancellableWait(SERVICE_DELAY);
onCancelConnect(cancelWait);
return wait
.then(() => createWebSocket(fullPath))
.then(newSocket => {
socket = newSocket;
socket.onmessage = receiveMessage;
});
},
disconnect() {
cancelConnect();
if (!socket) {
return;
}
socket.close();
socket = null;
},
};
};
export default createMirror();

View File

@ -0,0 +1,15 @@
import axios from '~/lib/utils/axios_utils';
export const baseUrl = projectPath => `/${projectPath}/ide_terminals`;
export const checkConfig = (projectPath, branch) =>
axios.post(`${baseUrl(projectPath)}/check_config`, {
branch,
format: 'json',
});
export const create = (projectPath, branch) =>
axios.post(baseUrl(projectPath), {
branch,
format: 'json',
});

View File

@ -0,0 +1,14 @@
import terminal from './plugins/terminal';
import terminalSync from './plugins/terminal_sync';
const plugins = () => [
terminal,
...(gon.features && gon.features.buildServiceProxy ? [terminalSync] : []),
];
export default (store, el) => {
// plugins is actually an array of plugin factories, so we have to create first then call
plugins().forEach(plugin => plugin(el)(store));
return store;
};

View File

@ -0,0 +1,98 @@
import Api from '~/api';
import httpStatus from '~/lib/utils/http_status';
import * as types from '../mutation_types';
import * as messages from '../messages';
import { CHECK_CONFIG, CHECK_RUNNERS, RETRY_RUNNERS_INTERVAL } from '../constants';
import * as terminalService from '../../../../services/terminals';
export const requestConfigCheck = ({ commit }) => {
commit(types.REQUEST_CHECK, CHECK_CONFIG);
};
export const receiveConfigCheckSuccess = ({ commit }) => {
commit(types.SET_VISIBLE, true);
commit(types.RECEIVE_CHECK_SUCCESS, CHECK_CONFIG);
};
export const receiveConfigCheckError = ({ commit, state }, e) => {
const { status } = e.response;
const { paths } = state;
const isVisible = status !== httpStatus.FORBIDDEN && status !== httpStatus.NOT_FOUND;
commit(types.SET_VISIBLE, isVisible);
const message = messages.configCheckError(status, paths.webTerminalConfigHelpPath);
commit(types.RECEIVE_CHECK_ERROR, { type: CHECK_CONFIG, message });
};
export const fetchConfigCheck = ({ dispatch, rootState, rootGetters }) => {
dispatch('requestConfigCheck');
const { currentBranchId } = rootState;
const { currentProject } = rootGetters;
terminalService
.checkConfig(currentProject.path_with_namespace, currentBranchId)
.then(() => {
dispatch('receiveConfigCheckSuccess');
})
.catch(e => {
dispatch('receiveConfigCheckError', e);
});
};
export const requestRunnersCheck = ({ commit }) => {
commit(types.REQUEST_CHECK, CHECK_RUNNERS);
};
export const receiveRunnersCheckSuccess = ({ commit, dispatch, state }, data) => {
if (data.length) {
commit(types.RECEIVE_CHECK_SUCCESS, CHECK_RUNNERS);
} else {
const { paths } = state;
commit(types.RECEIVE_CHECK_ERROR, {
type: CHECK_RUNNERS,
message: messages.runnersCheckEmpty(paths.webTerminalRunnersHelpPath),
});
dispatch('retryRunnersCheck');
}
};
export const receiveRunnersCheckError = ({ commit }) => {
commit(types.RECEIVE_CHECK_ERROR, {
type: CHECK_RUNNERS,
message: messages.UNEXPECTED_ERROR_RUNNERS,
});
};
export const retryRunnersCheck = ({ dispatch, state }) => {
// if the overall check has failed, don't worry about retrying
const check = state.checks[CHECK_CONFIG];
if (!check.isLoading && !check.isValid) {
return;
}
setTimeout(() => {
dispatch('fetchRunnersCheck', { background: true });
}, RETRY_RUNNERS_INTERVAL);
};
export const fetchRunnersCheck = ({ dispatch, rootGetters }, options = {}) => {
const { background = false } = options;
if (!background) {
dispatch('requestRunnersCheck');
}
const { currentProject } = rootGetters;
Api.projectRunners(currentProject.id, { params: { scope: 'active' } })
.then(({ data }) => {
dispatch('receiveRunnersCheckSuccess', data);
})
.catch(e => {
dispatch('receiveRunnersCheckError', e);
});
};

View File

@ -0,0 +1,5 @@
export * from './setup';
export * from './checks';
export * from './session_controls';
export * from './session_status';
export default () => {};

View File

@ -0,0 +1,118 @@
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import flash from '~/flash';
import * as types from '../mutation_types';
import * as messages from '../messages';
import * as terminalService from '../../../../services/terminals';
import { STARTING, STOPPING, STOPPED } from '../constants';
export const requestStartSession = ({ commit }) => {
commit(types.SET_SESSION_STATUS, STARTING);
};
export const receiveStartSessionSuccess = ({ commit, dispatch }, data) => {
commit(types.SET_SESSION, {
id: data.id,
status: data.status,
showPath: data.show_path,
cancelPath: data.cancel_path,
retryPath: data.retry_path,
terminalPath: data.terminal_path,
proxyWebsocketPath: data.proxy_websocket_path,
services: data.services,
});
dispatch('pollSessionStatus');
};
export const receiveStartSessionError = ({ dispatch }) => {
flash(messages.UNEXPECTED_ERROR_STARTING);
dispatch('killSession');
};
export const startSession = ({ state, dispatch, rootGetters, rootState }) => {
if (state.session && state.session.status === STARTING) {
return;
}
const { currentProject } = rootGetters;
const { currentBranchId } = rootState;
dispatch('requestStartSession');
terminalService
.create(currentProject.path_with_namespace, currentBranchId)
.then(({ data }) => {
dispatch('receiveStartSessionSuccess', data);
})
.catch(error => {
dispatch('receiveStartSessionError', error);
});
};
export const requestStopSession = ({ commit }) => {
commit(types.SET_SESSION_STATUS, STOPPING);
};
export const receiveStopSessionSuccess = ({ dispatch }) => {
dispatch('killSession');
};
export const receiveStopSessionError = ({ dispatch }) => {
flash(messages.UNEXPECTED_ERROR_STOPPING);
dispatch('killSession');
};
export const stopSession = ({ state, dispatch }) => {
const { cancelPath } = state.session;
dispatch('requestStopSession');
axios
.post(cancelPath)
.then(() => {
dispatch('receiveStopSessionSuccess');
})
.catch(err => {
dispatch('receiveStopSessionError', err);
});
};
export const killSession = ({ commit, dispatch }) => {
dispatch('stopPollingSessionStatus');
commit(types.SET_SESSION_STATUS, STOPPED);
};
export const restartSession = ({ state, dispatch, rootState }) => {
const { status, retryPath } = state.session;
const { currentBranchId } = rootState;
if (status !== STOPPED) {
return;
}
if (!retryPath) {
dispatch('startSession');
return;
}
dispatch('requestStartSession');
axios
.post(retryPath, { branch: currentBranchId, format: 'json' })
.then(({ data }) => {
dispatch('receiveStartSessionSuccess', data);
})
.catch(error => {
const responseStatus = error.response && error.response.status;
// We may have removed the build, in this case we'll just create a new session
if (
responseStatus === httpStatus.NOT_FOUND ||
responseStatus === httpStatus.UNPROCESSABLE_ENTITY
) {
dispatch('startSession');
} else {
dispatch('receiveStartSessionError', error);
}
});
};

View File

@ -0,0 +1,64 @@
import axios from '~/lib/utils/axios_utils';
import flash from '~/flash';
import * as types from '../mutation_types';
import * as messages from '../messages';
import { isEndingStatus } from '../utils';
export const pollSessionStatus = ({ state, dispatch, commit }) => {
dispatch('stopPollingSessionStatus');
dispatch('fetchSessionStatus');
const interval = setInterval(() => {
if (!state.session) {
dispatch('stopPollingSessionStatus');
} else {
dispatch('fetchSessionStatus');
}
}, 5000);
commit(types.SET_SESSION_STATUS_INTERVAL, interval);
};
export const stopPollingSessionStatus = ({ state, commit }) => {
const { sessionStatusInterval } = state;
if (!sessionStatusInterval) {
return;
}
clearInterval(sessionStatusInterval);
commit(types.SET_SESSION_STATUS_INTERVAL, 0);
};
export const receiveSessionStatusSuccess = ({ commit, dispatch }, data) => {
const status = data && data.status;
commit(types.SET_SESSION_STATUS, status);
if (isEndingStatus(status)) {
dispatch('killSession');
}
};
export const receiveSessionStatusError = ({ dispatch }) => {
flash(messages.UNEXPECTED_ERROR_STATUS);
dispatch('killSession');
};
export const fetchSessionStatus = ({ dispatch, state }) => {
if (!state.session) {
return;
}
const { showPath } = state.session;
axios
.get(showPath)
.then(({ data }) => {
dispatch('receiveSessionStatusSuccess', data);
})
.catch(error => {
dispatch('receiveSessionStatusError', error);
});
};

View File

@ -0,0 +1,14 @@
import * as types from '../mutation_types';
export const init = ({ dispatch }) => {
dispatch('fetchConfigCheck');
dispatch('fetchRunnersCheck');
};
export const hideSplash = ({ commit }) => {
commit(types.HIDE_SPLASH);
};
export const setPaths = ({ commit }, paths) => {
commit(types.SET_PATHS, paths);
};

View File

@ -0,0 +1,9 @@
export const CHECK_CONFIG = 'config';
export const CHECK_RUNNERS = 'runners';
export const RETRY_RUNNERS_INTERVAL = 10000;
export const STARTING = 'starting';
export const PENDING = 'pending';
export const RUNNING = 'running';
export const STOPPING = 'stopping';
export const STOPPED = 'stopped';

View File

@ -0,0 +1,19 @@
export const allCheck = state => {
const checks = Object.values(state.checks);
if (checks.some(check => check.isLoading)) {
return { isLoading: true };
}
const invalidCheck = checks.find(check => !check.isValid);
const isValid = !invalidCheck;
const message = !invalidCheck ? '' : invalidCheck.message;
return {
isLoading: false,
isValid,
message,
};
};
export default () => {};

View File

@ -0,0 +1,12 @@
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
export default () => ({
namespaced: true,
actions,
getters,
mutations,
state: state(),
});

View File

@ -0,0 +1,55 @@
import { escape } from 'lodash';
import { __, sprintf } from '~/locale';
import httpStatus from '~/lib/utils/http_status';
export const UNEXPECTED_ERROR_CONFIG = __(
'An unexpected error occurred while checking the project environment.',
);
export const UNEXPECTED_ERROR_RUNNERS = __(
'An unexpected error occurred while checking the project runners.',
);
export const UNEXPECTED_ERROR_STATUS = __(
'An unexpected error occurred while communicating with the Web Terminal.',
);
export const UNEXPECTED_ERROR_STARTING = __(
'An unexpected error occurred while starting the Web Terminal.',
);
export const UNEXPECTED_ERROR_STOPPING = __(
'An unexpected error occurred while stopping the Web Terminal.',
);
export const EMPTY_RUNNERS = __(
'Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}',
);
export const ERROR_CONFIG = __(
'Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}',
);
export const ERROR_PERMISSION = __(
'You do not have permission to run the Web Terminal. Please contact a project administrator.',
);
export const configCheckError = (status, helpUrl) => {
if (status === httpStatus.UNPROCESSABLE_ENTITY) {
return sprintf(
ERROR_CONFIG,
{
helpStart: `<a href="${escape(helpUrl)}" target="_blank">`,
helpEnd: '</a>',
},
false,
);
} else if (status === httpStatus.FORBIDDEN) {
return ERROR_PERMISSION;
}
return UNEXPECTED_ERROR_CONFIG;
};
export const runnersCheckEmpty = helpUrl =>
sprintf(
EMPTY_RUNNERS,
{
helpStart: `<a href="${escape(helpUrl)}" target="_blank">`,
helpEnd: '</a>',
},
false,
);

View File

@ -0,0 +1,11 @@
export const SET_VISIBLE = 'SET_VISIBLE';
export const HIDE_SPLASH = 'HIDE_SPLASH';
export const SET_PATHS = 'SET_PATHS';
export const REQUEST_CHECK = 'REQUEST_CHECK';
export const RECEIVE_CHECK_SUCCESS = 'RECEIVE_CHECK_SUCCESS';
export const RECEIVE_CHECK_ERROR = 'RECEIVE_CHECK_ERROR';
export const SET_SESSION = 'SET_SESSION';
export const SET_SESSION_STATUS = 'SET_SESSION_STATUS';
export const SET_SESSION_STATUS_INTERVAL = 'SET_SESSION_STATUS_INTERVAL';

View File

@ -0,0 +1,64 @@
import * as types from './mutation_types';
export default {
[types.SET_VISIBLE](state, isVisible) {
Object.assign(state, {
isVisible,
});
},
[types.HIDE_SPLASH](state) {
Object.assign(state, {
isShowSplash: false,
});
},
[types.SET_PATHS](state, paths) {
Object.assign(state, {
paths,
});
},
[types.REQUEST_CHECK](state, type) {
Object.assign(state.checks, {
[type]: {
isLoading: true,
},
});
},
[types.RECEIVE_CHECK_ERROR](state, { type, message }) {
Object.assign(state.checks, {
[type]: {
isLoading: false,
isValid: false,
message,
},
});
},
[types.RECEIVE_CHECK_SUCCESS](state, type) {
Object.assign(state.checks, {
[type]: {
isLoading: false,
isValid: true,
message: null,
},
});
},
[types.SET_SESSION](state, session) {
Object.assign(state, {
session,
});
},
[types.SET_SESSION_STATUS](state, status) {
const session = {
...(state.session || {}),
status,
};
Object.assign(state, {
session,
});
},
[types.SET_SESSION_STATUS_INTERVAL](state, sessionStatusInterval) {
Object.assign(state, {
sessionStatusInterval,
});
},
};

View File

@ -0,0 +1,13 @@
import { CHECK_CONFIG, CHECK_RUNNERS } from './constants';
export default () => ({
checks: {
[CHECK_CONFIG]: { isLoading: true },
[CHECK_RUNNERS]: { isLoading: true },
},
isVisible: false,
isShowSplash: true,
paths: {},
session: null,
sessionStatusInterval: 0,
});

View File

@ -0,0 +1,5 @@
import { STARTING, PENDING, RUNNING } from './constants';
export const isStartingStatus = status => status === STARTING || status === PENDING;
export const isRunningStatus = status => status === RUNNING;
export const isEndingStatus = status => !isStartingStatus(status) && !isRunningStatus(status);

View File

@ -0,0 +1,41 @@
import * as types from './mutation_types';
import mirror, { canConnect } from '../../../lib/mirror';
export const upload = ({ rootState, commit }) => {
commit(types.START_LOADING);
return mirror
.upload(rootState)
.then(() => {
commit(types.SET_SUCCESS);
})
.catch(err => {
commit(types.SET_ERROR, err);
});
};
export const stop = ({ commit }) => {
mirror.disconnect();
commit(types.STOP);
};
export const start = ({ rootState, commit }) => {
const { session } = rootState.terminal;
const path = session && session.proxyWebsocketPath;
if (!path || !canConnect(session)) {
return Promise.reject();
}
commit(types.START_LOADING);
return mirror
.connect(path)
.then(() => {
commit(types.SET_SUCCESS);
})
.catch(err => {
commit(types.SET_ERROR, err);
throw err;
});
};

View File

@ -0,0 +1,10 @@
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
export default () => ({
namespaced: true,
actions,
mutations,
state: state(),
});

View File

@ -0,0 +1,5 @@
import { __ } from '~/locale';
export const MSG_TERMINAL_SYNC_CONNECTING = __('Connecting to terminal sync service');
export const MSG_TERMINAL_SYNC_UPLOADING = __('Uploading changes to terminal');
export const MSG_TERMINAL_SYNC_RUNNING = __('Terminal sync service is running');

View File

@ -0,0 +1,4 @@
export const START_LOADING = 'START_LOADING';
export const SET_ERROR = 'SET_ERROR';
export const SET_SUCCESS = 'SET_SUCCESS';
export const STOP = 'STOP';

View File

@ -0,0 +1,22 @@
import * as types from './mutation_types';
export default {
[types.START_LOADING](state) {
state.isLoading = true;
state.isError = false;
},
[types.SET_ERROR](state, { message }) {
state.isLoading = false;
state.isError = true;
state.message = message;
},
[types.SET_SUCCESS](state) {
state.isLoading = false;
state.isError = false;
state.isStarted = true;
},
[types.STOP](state) {
state.isLoading = false;
state.isStarted = false;
},
};

View File

@ -0,0 +1,6 @@
export default () => ({
isLoading: false,
isStarted: false,
isError: false,
message: '',
});

View File

@ -0,0 +1,25 @@
import * as mutationTypes from '~/ide/stores/mutation_types';
import terminalModule from '../modules/terminal';
function getPathsFromData(el) {
return {
webTerminalSvgPath: el.dataset.eeWebTerminalSvgPath,
webTerminalHelpPath: el.dataset.eeWebTerminalHelpPath,
webTerminalConfigHelpPath: el.dataset.eeWebTerminalConfigHelpPath,
webTerminalRunnersHelpPath: el.dataset.eeWebTerminalRunnersHelpPath,
};
}
export default function createTerminalPlugin(el) {
return store => {
store.registerModule('terminal', terminalModule());
store.dispatch('terminal/setPaths', getPathsFromData(el));
store.subscribe(({ type }) => {
if (type === mutationTypes.SET_BRANCH_WORKING_REFERENCE) {
store.dispatch('terminal/init');
}
});
};
}

View File

@ -0,0 +1,49 @@
import { debounce } from 'lodash';
import eventHub from '~/ide/eventhub';
import terminalSyncModule from '../modules/terminal_sync';
import { isEndingStatus, isRunningStatus } from '../modules/terminal/utils';
const UPLOAD_DEBOUNCE = 200;
/**
* Registers and controls the terminalSync vuex module based on IDE events.
*
* - Watches the terminal session status state to control start/stop.
* - Listens for file change event to control upload.
*/
export default function createMirrorPlugin() {
return store => {
store.registerModule('terminalSync', terminalSyncModule());
const upload = debounce(() => {
store.dispatch(`terminalSync/upload`);
}, UPLOAD_DEBOUNCE);
const stop = () => {
store.dispatch(`terminalSync/stop`);
eventHub.$off('ide.files.change', upload);
};
const start = () => {
store
.dispatch(`terminalSync/start`)
.then(() => {
eventHub.$on('ide.files.change', upload);
})
.catch(() => {
// error is handled in store
});
};
store.watch(
x => x.terminal && x.terminal.session && x.terminal.session.status,
val => {
if (isRunningStatus(val)) {
start();
} else if (isEndingStatus(val)) {
stop();
}
},
);
};
}

View File

@ -4,7 +4,7 @@ import { last } from 'lodash';
import { __ } from '~/locale';
import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql';
import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql';
import { IMPORT_STATE, isInProgress } from '../utils';
import { IMPORT_STATE, isInProgress, extractJiraProjectsOptions } from '../utils';
import JiraImportForm from './jira_import_form.vue';
import JiraImportProgress from './jira_import_progress.vue';
import JiraImportSetup from './jira_import_setup.vue';
@ -36,10 +36,6 @@ export default {
type: String,
required: true,
},
jiraProjects: {
type: Array,
required: true,
},
projectPath: {
type: String,
required: true,
@ -51,6 +47,7 @@ export default {
},
data() {
return {
jiraImportDetails: {},
errorMessage: '',
showAlert: false,
selectedProject: undefined,
@ -65,6 +62,7 @@ export default {
};
},
update: ({ project }) => ({
projects: extractJiraProjectsOptions(project.services.nodes[0].projects.nodes),
status: project.jiraImportStatus,
imports: project.jiraImports.nodes,
}),
@ -75,17 +73,14 @@ export default {
},
computed: {
isImportInProgress() {
return isInProgress(this.jiraImportDetails?.status);
},
jiraProjectsOptions() {
return this.jiraProjects.map(([text, value]) => ({ text, value }));
return isInProgress(this.jiraImportDetails.status);
},
mostRecentImport() {
// The backend returns JiraImports ordered by created_at asc in app/models/project.rb
return last(this.jiraImportDetails?.imports);
return last(this.jiraImportDetails.imports);
},
numberOfPreviousImportsForProject() {
return this.jiraImportDetails?.imports?.reduce?.(
return this.jiraImportDetails.imports?.reduce?.(
(acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc),
0,
);
@ -202,7 +197,7 @@ export default {
v-model="selectedProject"
:import-label="importLabel"
:issues-path="issuesPath"
:jira-projects="jiraProjectsOptions"
:jira-projects="jiraImportDetails.projects"
@initiateJiraImport="initiateJiraImport"
/>
</div>

View File

@ -28,7 +28,6 @@ export default function mountJiraImportApp() {
isJiraConfigured: parseBoolean(el.dataset.isJiraConfigured),
issuesPath: el.dataset.issuesPath,
jiraIntegrationPath: el.dataset.jiraIntegrationPath,
jiraProjects: el.dataset.jiraProjects ? JSON.parse(el.dataset.jiraProjects) : [],
projectPath: el.dataset.projectPath,
setupIllustration: el.dataset.setupIllustration,
},

View File

@ -8,5 +8,17 @@ query($fullPath: ID!) {
...JiraImport
}
}
services(active: true, type: JIRA_SERVICE) {
nodes {
... on JiraService {
projects {
nodes {
key
name
}
}
}
}
}
}
}

View File

@ -13,6 +13,17 @@ export const isInProgress = state =>
export const isFinished = state => state === IMPORT_STATE.FINISHED;
/**
* Converts the list of Jira projects into a format consumable by GlFormSelect.
*
* @param {Object[]} projects - List of Jira projects
* @param {string} projects[].key - Jira project key
* @param {string} projects[].name - Jira project name
* @returns {Object[]} - List of Jira projects in a format consumable by GlFormSelect
*/
export const extractJiraProjectsOptions = projects =>
projects.map(({ key, name }) => ({ text: `${name} (${key})`, value: key }));
/**
* Calculates the label title for the most recent Jira import.
*

View File

@ -1,8 +1,8 @@
<script>
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlHeatmap } from '@gitlab/ui/dist/charts';
import dateformat from 'dateformat';
import { graphDataValidatorForValues } from '../../utils';
import { formatDate, timezones, formats } from '../../format_date';
export default {
components: {
@ -17,6 +17,11 @@ export default {
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
timezone: {
type: String,
required: false,
default: timezones.LOCAL,
},
},
data() {
return {
@ -43,7 +48,7 @@ export default {
return this.result.values.map(val => {
const [yLabel] = val;
return dateformat(new Date(yLabel), 'HH:MM:ss');
return formatDate(new Date(yLabel), { format: formats.shortTime, timezone: this.timezone });
});
},
result() {

View File

@ -2,18 +2,19 @@
import { omit, throttle } from 'lodash';
import { GlLink, GlDeprecatedButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { s__, __ } from '~/locale';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
import { panelTypes, chartHeight, lineTypes, lineWidths, dateFormats } from '../../constants';
import { panelTypes, chartHeight, lineTypes, lineWidths } from '../../constants';
import { getYAxisOptions, getChartGrid, getTooltipFormatter } from './options';
import { annotationsYAxis, generateAnnotationsSeries } from './annotations';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
import { formatDate, timezones, formats } from '../../format_date';
export const timestampToISODate = timestamp => new Date(timestamp).toISOString();
const THROTTLED_DATAZOOM_WAIT = 1000; // milliseconds
const timestampToISODate = timestamp => new Date(timestamp).toISOString();
const events = {
datazoom: 'datazoom',
@ -89,6 +90,11 @@ export default {
required: false,
default: '',
},
timezone: {
type: String,
required: false,
default: timezones.LOCAL,
},
},
data() {
return {
@ -163,7 +169,8 @@ export default {
name: __('Time'),
type: 'time',
axisLabel: {
formatter: date => dateFormat(date, dateFormats.timeOfDay),
formatter: date =>
formatDate(date, { format: formats.shortTime, timezone: this.timezone }),
},
axisPointer: {
snap: true,
@ -271,12 +278,13 @@ export default {
*/
formatAnnotationsTooltipText(params) {
return {
title: dateFormat(params.data?.tooltipData?.title, dateFormats.default),
title: formatDate(params.data?.tooltipData?.title, { timezone: this.timezone }),
content: params.data?.tooltipData?.content,
};
},
formatTooltipText(params) {
this.tooltip.title = dateFormat(params.value, dateFormats.default);
this.tooltip.title = formatDate(params.value, { timezone: this.timezone });
this.tooltip.content = [];
params.seriesData.forEach(dataPoint => {

View File

@ -127,11 +127,6 @@ export const lineWidths = {
default: 2,
};
export const dateFormats = {
timeOfDay: 'h:MM TT',
default: 'dd mmm yyyy, h:MMTT',
};
/**
* These Vuex store properties are allowed to be
* replaced dynamically after component has been created

View File

@ -0,0 +1,39 @@
import dateFormat from 'dateformat';
export const timezones = {
/**
* Renders a date with a local timezone
*/
LOCAL: 'LOCAL',
/**
* Renders at date with UTC
*/
UTC: 'UTC',
};
export const formats = {
shortTime: 'h:MM TT',
default: 'dd mmm yyyy, h:MMTT (Z)',
};
/**
* Formats a date for a metric dashboard or chart.
*
* Convenience wrapper of dateFormat with default formats
* and settings.
*
* dateFormat has some limitations and we could use `toLocaleString` instead
* See: https://gitlab.com/gitlab-org/gitlab/-/issues/219246
*
* @param {Date|String|Number} date
* @param {Object} options - Formatting options
* @param {string} options.format - Format or mask from `formats`.
* @param {string} options.timezone - Timezone abbreviation.
* Accepts "LOCAL" for the client local timezone.
*/
export const formatDate = (date, options = {}) => {
const { format = formats.default, timezone = timezones.LOCAL } = options;
const useUTC = timezone === timezones.UTC;
return dateFormat(date, format, useUTC);
};

View File

@ -1,3 +1,4 @@
import { startIde } from '~/ide/index';
import extendStore from '~/ide/stores/extend';
startIde();
startIde({ extendStore });

View File

@ -0,0 +1,4 @@
export const PARSE_FAILURE = 'parse_failure';
export const LOAD_FAILURE = 'load_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
export const DEFAULT = 'default';

View File

@ -1,11 +1,16 @@
<script>
import { GlAlert } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import DagGraph from './dag_graph.vue';
import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants';
import { parseData } from './utils';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Dag',
components: {
DagGraph,
GlAlert,
},
props: {
@ -18,15 +23,47 @@ export default {
data() {
return {
showFailureAlert: false,
failureType: null,
graphData: null,
};
},
errorTexts: {
[LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'),
[PARSE_FAILURE]: __('There was an error parsing the data for this graph.'),
[UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
computed: {
failure() {
switch (this.failureType) {
case LOAD_FAILURE:
return {
text: this.$options.errorTexts[LOAD_FAILURE],
variant: 'danger',
};
case PARSE_FAILURE:
return {
text: this.$options.errorTexts[PARSE_FAILURE],
variant: 'danger',
};
case UNSUPPORTED_DATA:
return {
text: this.$options.errorTexts[UNSUPPORTED_DATA],
variant: 'info',
};
default:
return {
text: this.$options.errorTexts[DEFAULT],
vatiant: 'danger',
};
}
},
shouldDisplayGraph() {
return !this.showFailureAlert;
return Boolean(!this.showFailureAlert && this.graphData);
},
},
mounted() {
const { drawGraph, reportFailure } = this;
const { processGraphData, reportFailure } = this;
if (!this.graphUrl) {
reportFailure();
@ -36,30 +73,43 @@ export default {
axios
.get(this.graphUrl)
.then(response => {
drawGraph(response.data);
processGraphData(response.data);
})
.catch(reportFailure);
.catch(() => reportFailure(LOAD_FAILURE));
},
methods: {
drawGraph(data) {
return data;
processGraphData(data) {
let parsed;
try {
parsed = parseData(data.stages);
} catch {
this.reportFailure(PARSE_FAILURE);
return;
}
if (parsed.links.length < 2) {
this.reportFailure(UNSUPPORTED_DATA);
return;
}
this.graphData = parsed;
},
hideAlert() {
this.showFailureAlert = false;
},
reportFailure() {
reportFailure(type) {
this.showFailureAlert = true;
this.failureType = type;
},
},
};
</script>
<template>
<div>
<gl-alert v-if="showFailureAlert" variant="danger" @dismiss="hideAlert">
{{ __('We are currently unable to fetch data for this graph.') }}
<gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">
{{ failure.text }}
</gl-alert>
<div v-if="shouldDisplayGraph" data-testid="dag-graph-container">
<!-- graph goes here -->
</div>
<dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" />
</div>
</template>

View File

@ -0,0 +1,381 @@
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
import { PARSE_FAILURE } from './constants';
import { createSankey, getMaxNodes, removeOrphanNodes } from './utils';
export default {
viewOptions: {
baseHeight: 300,
baseWidth: 1000,
minNodeHeight: 60,
nodeWidth: 16,
nodePadding: 25,
paddingForLabels: 100,
labelMargin: 8,
// can plausibly applied through CSS instead, TBD
baseOpacity: 0.8,
highlightIn: 1,
highlightOut: 0.2,
containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join(
' ',
),
},
gitLabColorRotation: [
'#e17223',
'#83ab4a',
'#5772ff',
'#b24800',
'#25d2d2',
'#006887',
'#487900',
'#d84280',
'#3547de',
'#6f3500',
'#006887',
'#275600',
'#b31756',
],
props: {
graphData: {
type: Object,
required: true,
},
},
data() {
return {
color: () => {},
width: 0,
height: 0,
};
},
mounted() {
let countedAndTransformed;
try {
countedAndTransformed = this.transformData(this.graphData);
} catch {
this.$emit('onFailure', PARSE_FAILURE);
return;
}
this.drawGraph(countedAndTransformed);
},
methods: {
addSvg() {
return d3
.select('.dag-graph-container')
.append('svg')
.attr('viewBox', [0, 0, this.width, this.height])
.attr('width', this.width)
.attr('height', this.height);
},
appendLinks(link) {
return (
link
.append('path')
.attr('d', this.createLinkPath)
.attr('stroke', ({ gradId }) => `url(#${gradId})`)
.style('stroke-linejoin', 'round')
// minus two to account for the rounded nodes
.attr('stroke-width', ({ width }) => Math.max(1, width - 2))
.attr('clip-path', ({ clipId }) => `url(#${clipId})`)
);
},
appendLabelAsForeignObject(d, i, n) {
const currentNode = n[i];
const { height, wrapperWidth, width, x, y, textAlign } = this.labelPosition(d);
const labelClasses = [
'gl-display-flex',
'gl-pointer-events-none',
'gl-flex-direction-column',
'gl-justify-content-center',
'gl-overflow-wrap-break',
].join(' ');
return (
d3
.select(currentNode)
.attr('requiredFeatures', 'http://www.w3.org/TR/SVG11/feature#Extensibility')
.attr('height', height)
/*
items with a 'max-content' width will have a wrapperWidth for the foreignObject
*/
.attr('width', wrapperWidth || width)
.attr('x', x)
.attr('y', y)
.classed('gl-overflow-visible', true)
.append('xhtml:div')
.classed(labelClasses, true)
.style('height', height)
.style('width', width)
.style('text-align', textAlign)
.text(({ name }) => name)
);
},
createAndAssignId(datum, field, modifier = '') {
const id = uniqueId(modifier);
/* eslint-disable-next-line no-param-reassign */
datum[field] = id;
return id;
},
createClip(link) {
/*
Because large link values can overrun their box, we create a clip path
to trim off the excess in charts that have few nodes per column and are
therefore tall.
The box is created by
M: moving to outside midpoint of the source node
V: drawing a vertical line to maximum of the bottom link edge or
the lowest edge of the node (can be d.y0 or d.y1 depending on the link's path)
H: drawing a horizontal line to the outside edge of the destination node
V: drawing a vertical line back up to the minimum of the top link edge or
the highest edge of the node (can be d.y0 or d.y1 depending on the link's path)
H: drawing a horizontal line back to the outside edge of the source node
Z: closing the path, back to the start point
*/
const clip = ({ y0, y1, source, target, width }) => {
const bottomLinkEdge = Math.max(y1, y0) + width / 2;
const topLinkEdge = Math.min(y0, y1) - width / 2;
/* eslint-disable @gitlab/require-i18n-strings */
return `
M${source.x0}, ${y1}
V${Math.max(bottomLinkEdge, y0, y1)}
H${target.x1}
V${Math.min(topLinkEdge, y0, y1)}
H${source.x0}
Z`;
/* eslint-enable @gitlab/require-i18n-strings */
};
return link
.append('clipPath')
.attr('id', d => {
return this.createAndAssignId(d, 'clipId', 'dag-clip');
})
.append('path')
.attr('d', clip);
},
createGradient(link) {
const gradient = link
.append('linearGradient')
.attr('id', d => {
return this.createAndAssignId(d, 'gradId', 'dag-grad');
})
.attr('gradientUnits', 'userSpaceOnUse')
.attr('x1', ({ source }) => source.x1)
.attr('x2', ({ target }) => target.x0);
gradient
.append('stop')
.attr('offset', '0%')
.attr('stop-color', ({ source }) => this.color(source));
gradient
.append('stop')
.attr('offset', '100%')
.attr('stop-color', ({ target }) => this.color(target));
},
createLinkPath({ y0, y1, source, target, width }, idx) {
const { nodeWidth } = this.$options.viewOptions;
/*
Creates a series of staggered midpoints for the link paths, so they
don't run along one channel and can be distinguished.
First, get a point staggered by index and link width, modulated by the link box
to find a point roughly between the nodes.
Then offset it by nodeWidth, so it doesn't run under any nodes at the left.
Determine where it would overlap at the right.
Finally, select the leftmost of these options:
- offset from the source node based on index + fudge;
- a fuzzy offset from the right node, using Math.random adds a little blur
- a hard offset from the end node, if random pushes it over
Then draw a line from the start node to the bottom-most point of the midline
up to the topmost point in that line and then to the middle of the end node
*/
const xValRaw = source.x1 + (((idx + 1) * width) % (target.x1 - source.x0));
const xValMin = xValRaw + nodeWidth;
const overlapPoint = source.x1 + (target.x0 - source.x1);
const xValMax = overlapPoint - nodeWidth * 1.4;
const midPointX = Math.min(xValMin, target.x0 - nodeWidth * 4 * Math.random(), xValMax);
return d3.line()([
[(source.x0 + source.x1) / 2, y0],
[midPointX, y0],
[midPointX, y1],
[(target.x0 + target.x1) / 2, y1],
]);
},
createLinks(svg, linksData) {
const link = this.generateLinks(svg, linksData);
this.createGradient(link);
this.createClip(link);
this.appendLinks(link);
},
createNodes(svg, nodeData) {
this.generateNodes(svg, nodeData);
this.labelNodes(svg, nodeData);
},
drawGraph({ maxNodesPerLayer, linksAndNodes }) {
const {
baseWidth,
baseHeight,
minNodeHeight,
nodeWidth,
nodePadding,
paddingForLabels,
} = this.$options.viewOptions;
this.width = baseWidth;
this.height = baseHeight + maxNodesPerLayer * minNodeHeight;
this.color = this.initColors();
const { links, nodes } = createSankey({
width: this.width,
height: this.height,
nodeWidth,
nodePadding,
paddingForLabels,
})(linksAndNodes);
const svg = this.addSvg();
this.createLinks(svg, links);
this.createNodes(svg, nodes);
},
generateLinks(svg, linksData) {
const linkContainerName = 'dag-link';
return svg
.append('g')
.attr('fill', 'none')
.attr('stroke-opacity', this.$options.viewOptions.baseOpacity)
.selectAll(`.${linkContainerName}`)
.data(linksData)
.enter()
.append('g')
.attr('id', d => {
return this.createAndAssignId(d, 'uid', linkContainerName);
})
.classed(`${linkContainerName} gl-cursor-pointer`, true);
},
generateNodes(svg, nodeData) {
const nodeContainerName = 'dag-node';
const { nodeWidth } = this.$options.viewOptions;
return svg
.append('g')
.selectAll(`.${nodeContainerName}`)
.data(nodeData)
.enter()
.append('line')
.classed(`${nodeContainerName} gl-cursor-pointer`, true)
.attr('id', d => {
return this.createAndAssignId(d, 'uid', nodeContainerName);
})
.attr('stroke', this.color)
.attr('stroke-width', nodeWidth)
.attr('stroke-linecap', 'round')
.attr('x1', d => Math.floor((d.x1 + d.x0) / 2))
.attr('x2', d => Math.floor((d.x1 + d.x0) / 2))
.attr('y1', d => d.y0 + 4)
.attr('y2', d => d.y1 - 4);
},
labelNodes(svg, nodeData) {
return svg
.append('g')
.classed('gl-font-sm', true)
.selectAll('text')
.data(nodeData)
.enter()
.append('foreignObject')
.each(this.appendLabelAsForeignObject);
},
initColors() {
const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
return ({ name }) => colorFn(name);
},
labelPosition({ x0, x1, y0, y1 }) {
const { paddingForLabels, labelMargin, nodePadding } = this.$options.viewOptions;
const firstCol = x0 <= paddingForLabels;
const lastCol = x1 >= this.width - paddingForLabels;
if (firstCol) {
return {
x: 0 + labelMargin,
y: y0,
height: `${y1 - y0}px`,
width: paddingForLabels - 2 * labelMargin,
textAlign: 'right',
};
}
if (lastCol) {
return {
x: this.width - paddingForLabels + labelMargin,
y: y0,
height: `${y1 - y0}px`,
width: paddingForLabels - 2 * labelMargin,
textAlign: 'left',
};
}
return {
x: (x1 + x0) / 2,
y: y0 - nodePadding,
height: `${nodePadding}px`,
width: 'max-content',
wrapperWidth: paddingForLabels - 2 * labelMargin,
textAlign: x0 < this.width / 2 ? 'left' : 'right',
};
},
transformData(parsed) {
const baseLayout = createSankey()(parsed);
const cleanedNodes = removeOrphanNodes(baseLayout.nodes);
const maxNodesPerLayer = getMaxNodes(cleanedNodes);
return {
maxNodesPerLayer,
linksAndNodes: {
links: parsed.links,
nodes: cleanedNodes,
},
};
},
},
};
</script>
<template>
<div :class="$options.viewOptions.containerClasses" data-testid="dag-graph-container">
<!-- graph goes here -->
</div>
</template>

View File

@ -141,7 +141,13 @@ export const parseData = data => {
values for the nodes and links in the graph.
*/
export const createSankey = ({ width, height, nodeWidth, nodePadding, paddingForLabels }) => {
export const createSankey = ({
width = 10,
height = 10,
nodeWidth = 10,
nodePadding = 10,
paddingForLabels = 1,
} = {}) => {
const sankeyGenerator = sankey()
.nodeId(({ name }) => name)
.nodeAlign(sankeyLeft)

View File

@ -887,6 +887,7 @@ $ide-commit-header-height: 48px;
padding-bottom: 0;
}
.ide-right-sidebar-terminal,
.ide-right-sidebar-clientside {
padding: 0;
}
@ -1154,3 +1155,22 @@ $ide-commit-header-height: 48px;
fill: var(--ide-text-color-secondary, $gl-text-color-secondary);
}
}
.ide-terminal {
@include ide-trace-view();
.terminal-wrapper {
background: $black;
color: $gray-darkest;
overflow: hidden;
}
.xterm {
height: 100%;
padding: $grid-size;
}
.xterm-viewport {
overflow-y: auto;
}
}

View File

@ -5,6 +5,9 @@ module ImportState
extend ActiveSupport::Concern
included do
scope :with_jid, -> { where.not(jid: nil) }
scope :without_jid, -> { where(jid: nil) }
# Refreshes the expiration time of the associated import job ID.
#
# This method can be used by asynchronous importers to refresh the status,

View File

@ -7,6 +7,7 @@ class JiraImportState < ApplicationRecord
self.table_name = 'jira_imports'
ERROR_MESSAGE_SIZE = 1000 # 1000 characters limit
STATUSES = { initial: 0, scheduled: 1, started: 2, failed: 3, finished: 4 }.freeze
belongs_to :project
@ -14,6 +15,7 @@ class JiraImportState < ApplicationRecord
belongs_to :label
scope :by_jira_project_key, -> (jira_project_key) { where(jira_project_key: jira_project_key) }
scope :with_status, ->(statuses) { where(status: statuses) }
validates :project, presence: true
validates :jira_project_key, presence: true
@ -25,6 +27,8 @@ class JiraImportState < ApplicationRecord
message: _('Cannot have multiple Jira imports running at the same time')
}
before_save :ensure_error_message_size
alias_method :scheduled_by, :user
state_machine :status, initial: :initial do
@ -65,6 +69,13 @@ class JiraImportState < ApplicationRecord
end
end
after_transition any => :failed do |state, transition|
arguments_hash = transition.args.first
error_message = arguments_hash&.dig(:error_message)
state.update_column(:error_message, error_message) if error_message.present?
end
# Supress warning:
# both JiraImportState and its :status machine have defined a different default for "status".
# although both have same value but represented in 2 ways: integer(0) and symbol(:initial)
@ -102,4 +113,18 @@ class JiraImportState < ApplicationRecord
def self.finished_imports_count
finished.sum(:imported_issues_count)
end
def mark_as_failed(error_message)
sanitized_message = Gitlab::UrlSanitizer.sanitize(error_message)
do_fail(error_message: error_message)
rescue ActiveRecord::ActiveRecordError => e
Gitlab::AppLogger.error("Error setting import status to failed: #{e.message}. Original error: #{sanitized_message}")
end
private
def ensure_error_message_size
self.error_message = error_message&.truncate(ERROR_MESSAGE_SIZE)
end
end

View File

@ -28,8 +28,8 @@ module JiraImport
rescue => ex
# in case project.save! raises an erorr
Gitlab::ErrorTracking.track_exception(ex, project_id: project.id)
jira_import&.do_fail!(error_message: ex.message)
build_error_response(ex.message)
jira_import.do_fail!
end
def build_jira_import

View File

@ -18,9 +18,9 @@
= _('Select the configured storage available for new repositories to be placed on.')
= link_to icon('question-circle'), help_page_path('administration/repository_storage_paths')
.form-check
= f.collection_check_boxes :repository_storages, Gitlab.config.repositories.storages, :first, :first, include_hidden: false do |b|
= b.check_box class: 'form-check-input'
= b.label class: 'label-bold form-check-label'
- @application_setting.repository_storages_weighted.each_key do |storage|
= f.text_field "repository_storages_weighted_#{storage}".to_sym, class: 'form-text-input'
= f.label storage, storage, class: 'label-bold form-check-label'
%br
= f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button"

View File

@ -3,7 +3,6 @@
issues_path: project_issues_path(@project),
jira_integration_path: edit_project_service_path(@project, :jira),
is_jira_configured: @project.jira_service.present?.to_s,
jira_projects: @jira_projects.to_json,
in_progress_illustration: image_path('illustrations/export-import.svg'),
setup_illustration: image_path('illustrations/manual_action.svg') } }
- else

View File

@ -155,6 +155,14 @@
:weight: 1
:idempotent:
:tags: []
- :name: cronjob:jira_import_stuck_jira_import_jobs
:feature_category: :importers
:has_external_dependencies:
:urgency: :low
:resource_boundary: :cpu
:weight: 1
:idempotent:
:tags: []
- :name: cronjob:namespaces_prune_aggregation_schedules
:feature_category: :source_code_management
:has_external_dependencies:

View File

@ -0,0 +1,82 @@
# frozen_string_literal: true
module Gitlab
module Import
module StuckImportJob
extend ActiveSupport::Concern
IMPORT_JOBS_EXPIRATION = 15.hours.seconds.to_i
included do
include ApplicationWorker
# rubocop:disable Scalability/CronWorkerContext
# This worker updates several import states inline and does not schedule
# other jobs. So no context needed
include CronjobQueue
# rubocop:enable Scalability/CronWorkerContext
feature_category :importers
worker_resource_boundary :cpu
end
def perform
stuck_imports_without_jid_count = mark_imports_without_jid_as_failed!
stuck_imports_with_jid_count = mark_imports_with_jid_as_failed!
track_metrics(stuck_imports_with_jid_count, stuck_imports_without_jid_count)
end
private
def track_metrics(with_jid_count, without_jid_count)
raise NotImplementedError
end
def mark_imports_without_jid_as_failed!
enqueued_import_states_without_jid.each do |import_state|
import_state.mark_as_failed(error_message)
end.size
end
def mark_imports_with_jid_as_failed!
jids_and_ids = enqueued_import_states_with_jid.pluck(:jid, :id).to_h # rubocop: disable CodeReuse/ActiveRecord
# Find the jobs that aren't currently running or that exceeded the threshold.
completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
return 0 unless completed_jids.any?
completed_import_state_ids = jids_and_ids.values_at(*completed_jids)
# We select the import states again, because they may have transitioned from
# scheduled/started to finished/failed while we were looking up their Sidekiq status.
completed_import_states = enqueued_import_states_with_jid.id_in(completed_import_state_ids)
completed_import_state_jids = completed_import_states.map { |import_state| import_state.jid }.join(', ')
Gitlab::Import::Logger.info(
message: 'Marked stuck import jobs as failed',
job_ids: completed_import_state_jids
)
completed_import_states.each do |import_state|
import_state.mark_as_failed(error_message)
end.size
end
def enqueued_import_states
raise NotImplementedError
end
def enqueued_import_states_with_jid
enqueued_import_states.with_jid
end
def enqueued_import_states_without_jid
enqueued_import_states.without_jid
end
def error_message
_("Import timed out. Import took longer than %{import_jobs_expiration} seconds") % { import_jobs_expiration: IMPORT_JOBS_EXPIRATION }
end
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Gitlab
module JiraImport
class StuckJiraImportJobsWorker # rubocop:disable Scalability/IdempotentWorker
include Gitlab::Import::StuckImportJob
private
def track_metrics(with_jid_count, without_jid_count)
Gitlab::Metrics.add_event(:stuck_jira_import_jobs,
jira_imports_without_jid_count: with_jid_count,
jira_imports_with_jid_count: without_jid_count)
end
def enqueued_import_states
JiraImportState.with_status([:scheduled, :started])
end
end
end
end

View File

@ -1,79 +1,21 @@
# frozen_string_literal: true
class StuckImportJobsWorker # rubocop:disable Scalability/IdempotentWorker
include ApplicationWorker
# rubocop:disable Scalability/CronWorkerContext
# This worker updates several import states inline and does not schedule
# other jobs. So no context needed
include CronjobQueue
# rubocop:enable Scalability/CronWorkerContext
include Gitlab::Import::StuckImportJob
feature_category :importers
worker_resource_boundary :cpu
IMPORT_JOBS_EXPIRATION = 15.hours.to_i
def perform
import_state_without_jid_count = mark_import_states_without_jid_as_failed!
import_state_with_jid_count = mark_import_states_with_jid_as_failed!
Gitlab::Metrics.add_event(:stuck_import_jobs,
projects_without_jid_count: import_state_without_jid_count,
projects_with_jid_count: import_state_with_jid_count)
end
IMPORT_JOBS_EXPIRATION = Gitlab::Import::StuckImportJob::IMPORT_JOBS_EXPIRATION
private
def mark_import_states_without_jid_as_failed!
enqueued_import_states_without_jid.each do |import_state|
import_state.mark_as_failed(error_message)
end.count
end
# rubocop: disable CodeReuse/ActiveRecord
def mark_import_states_with_jid_as_failed!
jids_and_ids = enqueued_import_states_with_jid.pluck(:jid, :id).to_h
# Find the jobs that aren't currently running or that exceeded the threshold.
completed_jids = Gitlab::SidekiqStatus.completed_jids(jids_and_ids.keys)
return unless completed_jids.any?
completed_import_state_ids = jids_and_ids.values_at(*completed_jids)
# We select the import states again, because they may have transitioned from
# scheduled/started to finished/failed while we were looking up their Sidekiq status.
completed_import_states = enqueued_import_states_with_jid.where(id: completed_import_state_ids)
completed_import_state_jids = completed_import_states.map { |import_state| import_state.jid }.join(', ')
Gitlab::Import::Logger.info(
message: 'Marked stuck import jobs as failed',
job_ids: completed_import_state_jids
def track_metrics(with_jid_count, without_jid_count)
Gitlab::Metrics.add_event(
:stuck_import_jobs,
projects_without_jid_count: without_jid_count,
projects_with_jid_count: with_jid_count
)
completed_import_states.each do |import_state|
import_state.mark_as_failed(error_message)
end.count
end
# rubocop: enable CodeReuse/ActiveRecord
def enqueued_import_states
ProjectImportState.with_status([:scheduled, :started])
end
# rubocop: disable CodeReuse/ActiveRecord
def enqueued_import_states_with_jid
enqueued_import_states.where.not(jid: nil)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def enqueued_import_states_without_jid
enqueued_import_states.where(jid: nil)
end
# rubocop: enable CodeReuse/ActiveRecord
def error_message
_("Import timed out. Import took longer than %{import_jobs_expiration} seconds") % { import_jobs_expiration: IMPORT_JOBS_EXPIRATION }
end
end

View File

@ -0,0 +1,5 @@
---
title: Set experiementation cookie for GitLab domain only
merge_request:
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add ApplicationSetting ui changes for repository_storages_weighted
merge_request: 33096
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Process stuck jira import jobs
merge_request: 32643
author:
type: added

View File

@ -454,6 +454,9 @@ Settings.cron_jobs['remove_unreferenced_lfs_objects_worker']['job_class'] = 'Rem
Settings.cron_jobs['stuck_import_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_import_jobs_worker']['cron'] ||= '15 * * * *'
Settings.cron_jobs['stuck_import_jobs_worker']['job_class'] = 'StuckImportJobsWorker'
Settings.cron_jobs['jira_import_stuck_jira_import_jobs'] ||= Settingslogic.new({})
Settings.cron_jobs['jira_import_stuck_jira_import_jobs']['cron'] ||= '* 0/15 * * *'
Settings.cron_jobs['jira_import_stuck_jira_import_jobs']['job_class'] = 'Gitlab::JiraImport::StuckJiraImportJobsWorker'
Settings.cron_jobs['stuck_export_jobs_worker'] ||= Settingslogic.new({})
Settings.cron_jobs['stuck_export_jobs_worker']['cron'] ||= '30 * * * *'
Settings.cron_jobs['stuck_export_jobs_worker']['job_class'] = 'StuckExportJobsWorker'

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
class AddErrorMessageColumnToJiraImports < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
unless column_exists?(:jira_imports, :error_message)
add_column :jira_imports, :error_message, :text
end
add_text_limit :jira_imports, :error_message, 1000
end
def down
return unless column_exists?(:jira_imports, :error_message)
remove_column :jira_imports, :error_message
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class SeedRepositoryStoragesWeighted < ActiveRecord::Migration[6.0]
class ApplicationSetting < ActiveRecord::Base
serialize :repository_storages
self.table_name = 'application_settings'
end
def up
ApplicationSetting.all.each do |settings|
storages = Gitlab.config.repositories.storages.keys.collect do |storage|
weight = settings.repository_storages.include?(storage) ? 100 : 0
[storage, weight]
end
settings.repository_storages_weighted = Hash[storages]
settings.save!
end
end
def down
end
end

View File

@ -3516,7 +3516,9 @@ CREATE TABLE public.jira_imports (
jid character varying(255),
jira_project_key character varying(255) NOT NULL,
jira_project_name character varying(255) NOT NULL,
scheduled_at timestamp with time zone
scheduled_at timestamp with time zone,
error_message text,
CONSTRAINT check_9ed451c5b1 CHECK ((char_length(error_message) <= 1000))
);
CREATE SEQUENCE public.jira_imports_id_seq
@ -13955,6 +13957,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200515155620
20200518091745
20200518133123
20200519101002
20200519115908
20200519171058
20200519194042
@ -13962,6 +13965,7 @@ COPY "schema_migrations" (version) FROM STDIN;
20200521022725
20200525114553
20200525121014
20200526000407
20200526120714
20200526153844
20200526164946

View File

@ -6,7 +6,7 @@
# For a list of all options, see https://errata-ai.github.io/vale/styles/
extends: existence
message: 'Curl commands must wrap URLs in double quotes ("): %s'
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#code-blocks
link: https://docs.gitlab.com/ee/development/documentation/styleguide.html#curl-commands
level: warning
scope: code
raw:

View File

@ -1,3 +1,10 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
---
# Environment Variables
GitLab exposes certain environment variables which can be used to override

View File

@ -1,3 +1,10 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference, howto
---
# External Pipeline Validation
You can use an external service for validating a pipeline before it's created.

View File

@ -1,4 +1,7 @@
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
description: "GitLab administrator: enable and disable GitLab features deployed behind feature flags"
---

View File

@ -147,6 +147,7 @@ successfully, you must replicate their data using some other means.
| [Conan Repository](../../../user/packages/conan_repository/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2346) | No | |
| [NuGet Repository](../../../user/packages/nuget_repository/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2346) | No | |
| [PyPi Repository](../../../user/packages/pypi_repository/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/2554) | No | |
| [Composer Repository](../../../user/packages/composer_repository/index.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/3096) | No | |
| [External merge request diffs](../../merge_request_diffs.md) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/33817) | No | |
| [Terraform State](../../terraform_state.md) | [No](https://gitlab.com/groups/gitlab-org/-/epics/3112)(*3*) | No | |
| [Vulnerability Export](../../../user/application_security/security_dashboard/#export-vulnerabilities-1) | [No](https://gitlab.com/groups/gitlab-org/-/epics/3111)(*3*) | No | | |

View File

@ -1,3 +1,10 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference, howto
---
# Jobs artifacts administration
> - Introduced in GitLab 8.2 and GitLab Runner 0.7.0.

View File

@ -1,3 +1,10 @@
---
stage: Verify
group: Runner
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
---
# Job logs
> [Renamed from job traces to job logs](https://gitlab.com/gitlab-org/gitlab/-/issues/29121) in GitLab 12.5.

View File

@ -15,6 +15,7 @@ The Packages feature allows GitLab to act as a repository for the following:
| Software repository | Description | Available in GitLab version |
| ------------------- | ----------- | --------------------------- |
| [PyPi Repository](../../user/packages/pypi_repository/index.md) | The GitLab PyPi Repository enables every project in GitLab to have its own space to store [PyPi](https://pypi.org/) packages. | 12.10+ |
| [Composer Repository](../../user/packages/composer_repository/index.md) | The GitLab Composer Repository enables every project in GitLab to have its own space to store [Composer](https://getcomposer.org/) packages. | 13.1+ |
| [NuGet Repository](../../user/packages/nuget_repository/index.md) | The GitLab NuGet Repository enables every project in GitLab to have its own space to store [NuGet](https://www.nuget.org/) packages. | 12.8+ |
| [Conan Repository](../../user/packages/conan_repository/index.md) | The GitLab Conan Repository enables every project in GitLab to have its own space to store [Conan](https://conan.io/) packages. | 12.4+ |
| [Maven Repository](../../user/packages/maven_repository/index.md) | The GitLab Maven Repository enables every project in GitLab to have its own space to store [Maven](https://maven.apache.org/) packages. | 11.3+ |

View File

@ -20,7 +20,7 @@ GET /projects/:id/packages
| `id` | integer/string | yes | ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
| `order_by`| string | no | The field to use as order. One of `created_at` (default), `name`, `version`, or `type`. |
| `sort` | string | no | The direction of the order, either `asc` (default) for ascending order or `desc` for descending order. |
| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi` or `nuget`. (_Introduced in GitLab 12.9_)
| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, or `nuget`. (_Introduced in GitLab 12.9_)
| `package_name` | string | no | Filter the project packages with a fuzzy search by name. (_Introduced in GitLab 12.9_)
```shell
@ -67,7 +67,7 @@ GET /groups/:id/packages
| `exclude_subgroups` | boolean | false | If the parameter is included as true, packages from projects from subgroups are not listed. Default is `false`. |
| `order_by`| string | no | The field to use as order. One of `created_at` (default), `name`, `version`, `type`, or `project_path`. |
| `sort` | string | no | The direction of the order, either `asc` (default) for ascending order or `desc` for descending order. |
| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi` or `nuget`. (_Introduced in GitLab 12.9_) |
| `package_type` | string | no | Filter the returned packages by type. One of `conan`, `maven`, `npm`, `pypi`, `composer`, or `nuget`. (_Introduced in GitLab 12.9_) |
| `package_name` | string | no | Filter the project packages with a fuzzy search by name. (_[Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/30980) in GitLab 13.0_)
```shell

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
comments: false
description: "Learn how to use GitLab CI/CD, the GitLab built-in Continuous Integration, Continuous Deployment, and Continuous Delivery toolset to build, test, and deploy your application."
type: index

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---

View File

@ -1,4 +1,7 @@
---
stage: Configure
group: Configure
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, concepts, howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: index, howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: reference
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
comments: false
type: index
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: concepts, howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Runner
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: concepts, howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: howto
---

View File

@ -1,4 +1,7 @@
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: concepts, howto
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
comments: false
type: index
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
disqus_identifier: 'https://docs.gitlab.com/ee/articles/artifactory_and_gitlab/index.html'
author: Fabio Busatto
author_gitlab: bikebilly

View File

@ -1,4 +1,7 @@
---
stage: Release
group: Release Management
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: tutorial
---

View File

@ -1,4 +1,7 @@
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
author: Dylan Griffith
author_gitlab: DylanGriffith
level: intermediate

View File

@ -1,4 +1,7 @@
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: tutorial
---

View File

@ -1,4 +1,7 @@
---
stage: Release
group: Progressive Delivery
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: tutorial
---

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
author: Ryan Hall
author_gitlab: blitzgren
level: intermediate

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Testing
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
author: Vincent Tunru
author_gitlab: Vinnl
level: advanced

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
disqus_identifier: 'https://docs.gitlab.com/ee/articles/laravel_with_gitlab_and_envoy/index.html'
author: Mehran Rasulian
author_gitlab: mehranrasulian

View File

@ -1,4 +1,7 @@
---
stage: Verify
group: Continuous Integration
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
type: tutorial
---

Some files were not shown because too many files have changed in this diff Show More