diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-29 18:08:26 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-05-29 18:08:26 +0000 |
commit | b64a8161c9442d82897a341d6bf935dd3e748b06 (patch) | |
tree | b9953db8607d1393aa8ac588a15f184dd8e183f6 /app | |
parent | 6e33325c1458cb31b4d36a7eec817fa00ec3faaf (diff) | |
download | gitlab-ce-b64a8161c9442d82897a341d6bf935dd3e748b06.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
61 files changed, 2169 insertions, 129 deletions
diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index e966110b914..562231f8002 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -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" /> diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 5585343f367..ddc126c3d77 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -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'; diff --git a/app/assets/javascripts/ide/components/ide_status_list.vue b/app/assets/javascripts/ide/components/ide_status_list.vue index 364e3f081a1..f0988adbd5d 100644 --- a/app/assets/javascripts/ide/components/ide_status_list.vue +++ b/app/assets/javascripts/ide/components/ide_status_list.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/panes/right.vue b/app/assets/javascripts/ide/components/panes/right.vue index caa05daf8bd..46ef08a45a9 100644 --- a/app/assets/javascripts/ide/components/panes/right.vue +++ b/app/assets/javascripts/ide/components/panes/right.vue @@ -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', + }, ]; }, }, diff --git a/app/assets/javascripts/ide/components/terminal/empty_state.vue b/app/assets/javascripts/ide/components/terminal/empty_state.vue new file mode 100644 index 00000000000..9841f1ece48 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/empty_state.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/terminal/session.vue b/app/assets/javascripts/ide/components/terminal/session.vue new file mode 100644 index 00000000000..a8fe9ea6866 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/session.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/terminal/terminal.vue b/app/assets/javascripts/ide/components/terminal/terminal.vue new file mode 100644 index 00000000000..0ee4107f9ab --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/terminal.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/terminal/terminal_controls.vue b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue new file mode 100644 index 00000000000..4c13b4ef103 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/terminal_controls.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/terminal/view.vue b/app/assets/javascripts/ide/components/terminal/view.vue new file mode 100644 index 00000000000..db97e95eed9 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal/view.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue new file mode 100644 index 00000000000..deb13b5615e --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status.vue @@ -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> diff --git a/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue new file mode 100644 index 00000000000..afaf06f7f68 --- /dev/null +++ b/app/assets/javascripts/ide/components/terminal_sync/terminal_sync_status_safe.vue @@ -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> diff --git a/app/assets/javascripts/ide/constants.js b/app/assets/javascripts/ide/constants.js index 555754c7104..59b1969face 100644 --- a/app/assets/javascripts/ide/constants.js +++ b/app/assets/javascripts/ide/constants.js @@ -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 = { diff --git a/app/assets/javascripts/ide/lib/create_diff.js b/app/assets/javascripts/ide/lib/create_diff.js new file mode 100644 index 00000000000..3e915afdbcb --- /dev/null +++ b/app/assets/javascripts/ide/lib/create_diff.js @@ -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; diff --git a/app/assets/javascripts/ide/lib/create_file_diff.js b/app/assets/javascripts/ide/lib/create_file_diff.js new file mode 100644 index 00000000000..5ae4993321c --- /dev/null +++ b/app/assets/javascripts/ide/lib/create_file_diff.js @@ -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; diff --git a/app/assets/javascripts/ide/lib/mirror.js b/app/assets/javascripts/ide/lib/mirror.js new file mode 100644 index 00000000000..a516c28ad7a --- /dev/null +++ b/app/assets/javascripts/ide/lib/mirror.js @@ -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(); diff --git a/app/assets/javascripts/ide/services/terminals.js b/app/assets/javascripts/ide/services/terminals.js new file mode 100644 index 00000000000..17b4329037d --- /dev/null +++ b/app/assets/javascripts/ide/services/terminals.js @@ -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', + }); diff --git a/app/assets/javascripts/ide/stores/extend.js b/app/assets/javascripts/ide/stores/extend.js new file mode 100644 index 00000000000..1c1636cf6ca --- /dev/null +++ b/app/assets/javascripts/ide/stores/extend.js @@ -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; +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js new file mode 100644 index 00000000000..43b6650b241 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/checks.js @@ -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); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js new file mode 100644 index 00000000000..112b3794114 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js @@ -0,0 +1,5 @@ +export * from './setup'; +export * from './checks'; +export * from './session_controls'; +export * from './session_status'; +export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js new file mode 100644 index 00000000000..d3dcb9dd125 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -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); + } + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js new file mode 100644 index 00000000000..59ba1605c47 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js @@ -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); + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js new file mode 100644 index 00000000000..78ad94f8a91 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/setup.js @@ -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); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/constants.js b/app/assets/javascripts/ide/stores/modules/terminal/constants.js new file mode 100644 index 00000000000..f7ae9d8f4ea --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/constants.js @@ -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'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/getters.js b/app/assets/javascripts/ide/stores/modules/terminal/getters.js new file mode 100644 index 00000000000..6d64ee4ab6e --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/getters.js @@ -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 () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/index.js b/app/assets/javascripts/ide/stores/modules/terminal/index.js new file mode 100644 index 00000000000..ef1289e1722 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/index.js @@ -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(), +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js new file mode 100644 index 00000000000..38c5a8a28d8 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -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, + ); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js new file mode 100644 index 00000000000..b6a6f28abfa --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/mutation_types.js @@ -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'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js new file mode 100644 index 00000000000..37f40af9c2e --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js @@ -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, + }); + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/state.js b/app/assets/javascripts/ide/stores/modules/terminal/state.js new file mode 100644 index 00000000000..f35a10ed2fe --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/state.js @@ -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, +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal/utils.js b/app/assets/javascripts/ide/stores/modules/terminal/utils.js new file mode 100644 index 00000000000..c30136b5277 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal/utils.js @@ -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); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js new file mode 100644 index 00000000000..2fee6b4e974 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/actions.js @@ -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; + }); +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js new file mode 100644 index 00000000000..795c2fad724 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/index.js @@ -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(), +}); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js new file mode 100644 index 00000000000..e50e1a1406b --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/messages.js @@ -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'); diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js new file mode 100644 index 00000000000..ec809540c18 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutation_types.js @@ -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'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js new file mode 100644 index 00000000000..70ed137776a --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/mutations.js @@ -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; + }, +}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js new file mode 100644 index 00000000000..7ec3e38f675 --- /dev/null +++ b/app/assets/javascripts/ide/stores/modules/terminal_sync/state.js @@ -0,0 +1,6 @@ +export default () => ({ + isLoading: false, + isStarted: false, + isError: false, + message: '', +}); diff --git a/app/assets/javascripts/ide/stores/plugins/terminal.js b/app/assets/javascripts/ide/stores/plugins/terminal.js new file mode 100644 index 00000000000..66539c7bd4f --- /dev/null +++ b/app/assets/javascripts/ide/stores/plugins/terminal.js @@ -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'); + } + }); + }; +} diff --git a/app/assets/javascripts/ide/stores/plugins/terminal_sync.js b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js new file mode 100644 index 00000000000..c60bba4293a --- /dev/null +++ b/app/assets/javascripts/ide/stores/plugins/terminal_sync.js @@ -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(); + } + }, + ); + }; +} diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index d1570f52c8c..e5083f01a8a 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -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> diff --git a/app/assets/javascripts/jira_import/index.js b/app/assets/javascripts/jira_import/index.js index b576668fe7c..924cc7e6864 100644 --- a/app/assets/javascripts/jira_import/index.js +++ b/app/assets/javascripts/jira_import/index.js @@ -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, }, diff --git a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql index aa8d03c7f17..2aacc5cf668 100644 --- a/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql +++ b/app/assets/javascripts/jira_import/queries/get_jira_import_details.query.graphql @@ -8,5 +8,17 @@ query($fullPath: ID!) { ...JiraImport } } + services(active: true, type: JIRA_SERVICE) { + nodes { + ... on JiraService { + projects { + nodes { + key + name + } + } + } + } + } } } diff --git a/app/assets/javascripts/jira_import/utils.js b/app/assets/javascripts/jira_import/utils.js index aa10dfc8099..e82a3f44a29 100644 --- a/app/assets/javascripts/jira_import/utils.js +++ b/app/assets/javascripts/jira_import/utils.js @@ -14,6 +14,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. * * @param {Object[]} jiraImports - List of Jira imports diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index 55a25ee09fd..f6f266dacf3 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -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() { diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 8f37a12af75..a9af533bbdc 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -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 => { diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index ec1570f0ef9..bb15438b040 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -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 diff --git a/app/assets/javascripts/monitoring/format_date.js b/app/assets/javascripts/monitoring/format_date.js new file mode 100644 index 00000000000..a50d441a09e --- /dev/null +++ b/app/assets/javascripts/monitoring/format_date.js @@ -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); +}; diff --git a/app/assets/javascripts/pages/ide/index.js b/app/assets/javascripts/pages/ide/index.js index d192df3561e..15933256e75 100644 --- a/app/assets/javascripts/pages/ide/index.js +++ b/app/assets/javascripts/pages/ide/index.js @@ -1,3 +1,4 @@ import { startIde } from '~/ide/index'; +import extendStore from '~/ide/stores/extend'; -startIde(); +startIde({ extendStore }); diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js new file mode 100644 index 00000000000..252d2d8e4ad --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/constants.js @@ -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'; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 297affcd41f..13cbf47ad4f 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -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> diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue new file mode 100644 index 00000000000..c6187827510 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -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> diff --git a/app/assets/javascripts/pipelines/components/dag/utils.js b/app/assets/javascripts/pipelines/components/dag/utils.js index 20d1f785187..76cfd75f9fd 100644 --- a/app/assets/javascripts/pipelines/components/dag/utils.js +++ b/app/assets/javascripts/pipelines/components/dag/utils.js @@ -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) diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 199778a8529..e3d23ed0cbc 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -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; + } +} diff --git a/app/models/concerns/import_state/sidekiq_job_tracker.rb b/app/models/concerns/import_state/sidekiq_job_tracker.rb index 55f171d158d..acbcde1af51 100644 --- a/app/models/concerns/import_state/sidekiq_job_tracker.rb +++ b/app/models/concerns/import_state/sidekiq_job_tracker.rb @@ -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, diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb index 9737ebcf8fa..2d952c552a8 100644 --- a/app/models/jira_import_state.rb +++ b/app/models/jira_import_state.rb @@ -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 diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index 59fd463022f..615d06c7c3d 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -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 diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 6fabafe3fc1..8f431a95425 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -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" diff --git a/app/views/projects/import/jira/show.html.haml b/app/views/projects/import/jira/show.html.haml index cddd97cbc84..0f5db82d7dd 100644 --- a/app/views/projects/import/jira/show.html.haml +++ b/app/views/projects/import/jira/show.html.haml @@ -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 diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 11972879a8d..0dded4d6474 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -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: diff --git a/app/workers/gitlab/import/stuck_import_job.rb b/app/workers/gitlab/import/stuck_import_job.rb new file mode 100644 index 00000000000..16be7a77ab1 --- /dev/null +++ b/app/workers/gitlab/import/stuck_import_job.rb @@ -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 diff --git a/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb new file mode 100644 index 00000000000..5e675193a8c --- /dev/null +++ b/app/workers/gitlab/jira_import/stuck_jira_import_jobs_worker.rb @@ -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 diff --git a/app/workers/stuck_import_jobs_worker.rb b/app/workers/stuck_import_jobs_worker.rb index 8e8bf537bd5..71e8f7a70ec 100644 --- a/app/workers/stuck_import_jobs_worker.rb +++ b/app/workers/stuck_import_jobs_worker.rb @@ -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 |