Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
6e33325c14
commit
b64a8161c9
|
@ -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" />
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
|
@ -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',
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -0,0 +1,5 @@
|
|||
export * from './setup';
|
||||
export * from './checks';
|
||||
export * from './session_controls';
|
||||
export * from './session_status';
|
||||
export default () => {};
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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';
|
|
@ -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 () => {};
|
|
@ -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(),
|
||||
});
|
|
@ -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,
|
||||
);
|
|
@ -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';
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
});
|
|
@ -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);
|
|
@ -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;
|
||||
});
|
||||
};
|
|
@ -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(),
|
||||
});
|
|
@ -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');
|
|
@ -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';
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -0,0 +1,6 @@
|
|||
export default () => ({
|
||||
isLoading: false,
|
||||
isStarted: false,
|
||||
isError: false,
|
||||
message: '',
|
||||
});
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -8,5 +8,17 @@ query($fullPath: ID!) {
|
|||
...JiraImport
|
||||
}
|
||||
}
|
||||
services(active: true, type: JIRA_SERVICE) {
|
||||
nodes {
|
||||
... on JiraService {
|
||||
projects {
|
||||
nodes {
|
||||
key
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
};
|
|
@ -1,3 +1,4 @@
|
|||
import { startIde } from '~/ide/index';
|
||||
import extendStore from '~/ide/stores/extend';
|
||||
|
||||
startIde();
|
||||
startIde({ extendStore });
|
||||
|
|
|
@ -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';
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Set experiementation cookie for GitLab domain only
|
||||
merge_request:
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add ApplicationSetting ui changes for repository_storages_weighted
|
||||
merge_request: 33096
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Process stuck jira import jobs
|
||||
merge_request: 32643
|
||||
author:
|
||||
type: added
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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"
|
||||
---
|
||||
|
|
|
@ -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 | | |
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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+ |
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
---
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue