diff options
Diffstat (limited to 'app/assets/javascripts/ci/job_details')
43 files changed, 4081 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/job_details/components/empty_state.vue b/app/assets/javascripts/ci/job_details/components/empty_state.vue new file mode 100644 index 00000000000..5756d4a71df --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/empty_state.vue @@ -0,0 +1,100 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; + +export default { + components: { + GlLink, + ManualVariablesForm, + }, + props: { + illustrationPath: { + type: String, + required: true, + }, + illustrationSizeClass: { + type: String, + required: true, + }, + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + title: { + type: String, + required: true, + }, + content: { + type: String, + required: false, + default: null, + }, + playable: { + type: Boolean, + required: true, + default: false, + }, + scheduled: { + type: Boolean, + required: false, + default: false, + }, + action: { + type: Object, + required: false, + default: null, + validator(value) { + return ( + value === null || + (Object.prototype.hasOwnProperty.call(value, 'path') && + Object.prototype.hasOwnProperty.call(value, 'method') && + Object.prototype.hasOwnProperty.call(value, 'button_title')) + ); + }, + }, + }, + computed: { + shouldRenderManualVariables() { + return this.playable && !this.scheduled; + }, + }, +}; +</script> +<template> + <div class="row empty-state"> + <div class="col-12"> + <div :class="illustrationSizeClass" class="svg-content"> + <img :src="illustrationPath" /> + </div> + </div> + + <div class="col-12"> + <div class="text-content"> + <h4 class="text-center" data-testid="job-empty-state-title">{{ title }}</h4> + + <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> + </div> + <manual-variables-form + v-if="shouldRenderManualVariables" + :is-retryable="isRetryable" + :job-id="jobId" + @hideManualVariablesForm="$emit('hideManualVariablesForm')" + /> + <div v-if="action && !shouldRenderManualVariables" class="text-content"> + <div class="text-center"> + <gl-link + :href="action.path" + :data-method="action.method" + class="btn gl-button btn-confirm gl-text-decoration-none!" + data-testid="job-empty-state-action" + >{{ action.button_title }}</gl-link + > + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/environments_block.vue b/app/assets/javascripts/ci/job_details/components/environments_block.vue new file mode 100644 index 00000000000..4046e1ade82 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/environments_block.vue @@ -0,0 +1,214 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { __ } from '~/locale'; + +export default { + creatingEnvironment: 'creating', + components: { + CiIcon, + GlSprintf, + GlLink, + }, + props: { + deploymentStatus: { + type: Object, + required: true, + }, + deploymentCluster: { + type: Object, + required: false, + default: null, + }, + iconStatus: { + type: Object, + required: true, + }, + }, + computed: { + environment() { + switch (this.deploymentStatus.status) { + case 'last': + return this.lastEnvironmentMessage(); + case 'out_of_date': + return this.outOfDateEnvironmentMessage(); + case 'failed': + return this.failedEnvironmentMessage(); + case this.$options.creatingEnvironment: + return this.creatingEnvironmentMessage(); + default: + return ''; + } + }, + environmentLink() { + if (this.hasEnvironment) { + return { + link: this.deploymentStatus.environment.environment_path, + name: this.deploymentStatus.environment.name, + }; + } + return {}; + }, + hasLastDeployment() { + return this.hasEnvironment && this.deploymentStatus.environment.last_deployment; + }, + lastDeployment() { + return this.hasLastDeployment ? this.deploymentStatus.environment.last_deployment : {}; + }, + hasEnvironment() { + return !isEmpty(this.deploymentStatus.environment); + }, + lastDeploymentPath() { + return !isEmpty(this.lastDeployment.deployable) + ? this.lastDeployment.deployable.build_path + : ''; + }, + hasCluster() { + return Boolean(this.deploymentCluster) && Boolean(this.deploymentCluster.name); + }, + clusterNameOrLink() { + if (!this.hasCluster) { + return ''; + } + + const { name, path } = this.deploymentCluster; + + return { + path, + name, + }; + }, + kubernetesNamespace() { + return this.hasCluster ? this.deploymentCluster.kubernetes_namespace : null; + }, + deploymentLink() { + return { + path: this.lastDeploymentPath, + name: + this.deploymentStatus.status === this.$options.creatingEnvironment + ? __('latest deployment') + : __('most recent deployment'), + }; + }, + }, + methods: { + failedEnvironmentMessage() { + return __('The deployment of this job to %{environmentLink} did not succeed.'); + }, + lastEnvironmentMessage() { + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ); + } + // we know the cluster but not the namespace + return __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.'); + } + // not a cluster deployment + return __('This job is deployed to %{environmentLink}.'); + }, + outOfDateEnvironmentMessage() { + if (this.hasLastDeployment) { + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. View the %{deploymentLink}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.', + ); + } + // not a cluster deployment + return __( + 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.', + ); + } + // no last deployment, i.e. this is the first deployment + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}.', + ); + } + // not a cluster deployment + return __('This job is an out-of-date deployment to %{environmentLink}.'); + }, + creatingEnvironmentMessage() { + if (this.hasLastDeployment) { + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. This will overwrite the %{deploymentLink}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.', + ); + } + // not a cluster deployment + return __( + 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.', + ); + } + // no last deployment, i.e. this is the first deployment + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}.', + ); + } + // not a cluster deployment + return __('This job is creating a deployment to %{environmentLink}.'); + }, + }, +}; +</script> +<template> + <div class="gl-mt-3 gl-mb-3 js-environment-container"> + <div class="environment-information"> + <ci-icon :status="iconStatus" /> + <p class="inline gl-mb-0"> + <gl-sprintf :message="environment"> + <template #environmentLink> + <gl-link + v-if="hasEnvironment" + :href="environmentLink.link" + data-testid="job-environment-link" + >{{ environmentLink.name }}</gl-link + > + </template> + <template #clusterNameOrLink> + <gl-link + v-if="clusterNameOrLink.path" + :href="clusterNameOrLink.path" + data-testid="job-cluster-link" + >{{ clusterNameOrLink.name }}</gl-link + > + <template v-else>{{ clusterNameOrLink.name }}</template> + </template> + <template #kubernetesNamespace>{{ kubernetesNamespace }}</template> + <template #deploymentLink> + <gl-link :href="deploymentLink.path" data-testid="job-deployment-link">{{ + deploymentLink.name + }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/erased_block.vue b/app/assets/javascripts/ci/job_details/components/erased_block.vue new file mode 100644 index 00000000000..a815689659e --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/erased_block.vue @@ -0,0 +1,49 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + TimeagoTooltip, + }, + props: { + user: { + type: Object, + required: false, + default: () => ({}), + }, + erasedAt: { + type: String, + required: true, + }, + }, + computed: { + isErasedByUser() { + return !isEmpty(this.user); + }, + }, +}; +</script> +<template> + <div class="gl-mt-3"> + <gl-alert variant="warning" :dismissible="false"> + <template v-if="isErasedByUser"> + <gl-sprintf :message="s__('Job|Job has been erased by %{userLink}')"> + <template #userLink> + <gl-link :href="user.web_url" target="_blank">{{ user.username }}</gl-link> + </template> + </gl-sprintf> + </template> + + <template v-else> + {{ s__('Job|Job has been erased') }} + </template> + + <timeago-tooltip :time="erasedAt" /> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/job_header.vue b/app/assets/javascripts/ci/job_details/components/job_header.vue new file mode 100644 index 00000000000..13f3eebd447 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/job_header.vue @@ -0,0 +1,148 @@ +<script> +import { GlTooltipDirective, GlButton, GlAvatarLink, GlAvatarLabeled, GlTooltip } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { glEmojiTag } from '~/emoji'; +import { __, sprintf } from '~/locale'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + CiBadgeLink, + TimeagoTooltip, + GlButton, + GlAvatarLink, + GlAvatarLabeled, + GlTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + EMOJI_REF: 'EMOJI_REF', + props: { + status: { + type: Object, + required: true, + }, + name: { + type: String, + required: true, + }, + time: { + type: String, + required: true, + }, + user: { + type: Object, + required: true, + }, + shouldRenderTriggeredLabel: { + type: Boolean, + required: true, + }, + }, + + computed: { + userAvatarAltText() { + return sprintf(__(`%{username}'s avatar`), { username: this.user.name }); + }, + userPath() { + // GraphQL returns `webPath` and Rest `path` + return this.user?.webPath || this.user?.path; + }, + avatarUrl() { + // GraphQL returns `avatarUrl` and Rest `avatar_url` + return this.user?.avatarUrl || this.user?.avatar_url; + }, + webUrl() { + // GraphQL returns `webUrl` and Rest `web_url` + return this.user?.webUrl || this.user?.web_url; + }, + statusTooltipHTML() { + // Rest `status_tooltip_html` which is a ready to work + // html for the emoji and the status text inside a tooltip. + // GraphQL returns `status.emoji` and `status.message` which + // needs to be combined to make the html we want. + const { emoji } = this.user?.status || {}; + const emojiHtml = emoji ? glEmojiTag(emoji) : ''; + + return emojiHtml || this.user?.status_tooltip_html; + }, + message() { + return this.user?.status?.message; + }, + userId() { + return isGid(this.user?.id) ? getIdFromGraphQLId(this.user?.id) : this.user?.id; + }, + }, + + methods: { + onClickSidebarButton() { + this.$emit('clickedSidebarButton'); + }, + }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, +}; +</script> + +<template> + <header + class="page-content-header gl-md-display-flex gl-min-h-7" + data-testid="job-header-content" + > + <section class="header-main-content gl-mr-3"> + <ci-badge-link class="gl-mr-3" :status="status" /> + + <strong data-testid="job-name">{{ name }}</strong> + + <template v-if="shouldRenderTriggeredLabel">{{ __('started') }}</template> + <template v-else>{{ __('created') }}</template> + + <timeago-tooltip :time="time" /> + + {{ __('by') }} + + <template v-if="user"> + <gl-avatar-link + :data-user-id="userId" + :data-username="user.username" + :data-name="user.name" + :href="webUrl" + target="_blank" + class="js-user-link gl-vertical-align-middle gl-mx-2 gl-align-items-center" + > + <gl-avatar-labeled + :size="24" + :src="avatarUrl" + :label="user.name" + class="gl-display-none gl-sm-display-inline-flex gl-mx-1" + /> + <strong class="author gl-display-inline gl-sm-display-none!">@{{ user.username }}</strong> + <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> + {{ message }} + </gl-tooltip> + <span + v-if="statusTooltipHTML" + :ref="$options.EMOJI_REF" + v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML" + class="gl-ml-2" + :data-testid="message" + ></span> + </gl-avatar-link> + </template> + </section> + + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> + <section v-if="$slots.default" data-testid="job-header-action-buttons" class="gl-display-flex"> + <slot></slot> + </section> + <gl-button + class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle" + icon="chevron-double-lg-left" + :aria-label="__('Toggle sidebar')" + @click="onClickSidebarButton" + /> + </header> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue new file mode 100644 index 00000000000..419efcba46d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue @@ -0,0 +1,260 @@ +<script> +import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui'; +import { scrollToElement, backOff } from '~/lib/utils/common_utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { __, s__, sprintf } from '~/locale'; +import { compactJobLog } from '~/ci/job_details/utils'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + i18n: { + scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), + scrollToTopButtonLabel: s__('Job|Scroll to top'), + scrollToNextFailureButtonLabel: s__('Job|Scroll to next failure'), + showRawButtonLabel: s__('Job|Show complete raw'), + searchPlaceholder: s__('Job|Search job log'), + noResults: s__('Job|No search results found'), + searchPopoverTitle: s__('Job|Job log search'), + searchPopoverDescription: s__( + 'Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size.', + ), + logLineNumberNotFound: s__('Job|We could not find this element'), + }, + components: { + GlLink, + GlButton, + GlSearchBoxByClick, + HelpPopover, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + props: { + size: { + type: Number, + required: true, + }, + rawPath: { + type: String, + required: false, + default: null, + }, + isScrollTopDisabled: { + type: Boolean, + required: true, + }, + isScrollBottomDisabled: { + type: Boolean, + required: true, + }, + isScrollingDown: { + type: Boolean, + required: true, + }, + isJobLogSizeVisible: { + type: Boolean, + required: true, + }, + isComplete: { + type: Boolean, + required: true, + }, + jobLog: { + type: Array, + required: true, + }, + }, + data() { + return { + searchTerm: '', + searchResults: [], + failureCount: null, + failureIndex: 0, + }; + }, + computed: { + jobLogSize() { + return sprintf(__('Showing last %{size} of log -'), { + size: numberToHumanSize(this.size), + }); + }, + showJumpToFailures() { + return this.glFeatures.jobLogJumpToFailures; + }, + hasFailures() { + return this.failureCount > 0; + }, + shouldDisableJumpToFailures() { + return !this.hasFailures; + }, + }, + mounted() { + this.checkFailureCount(); + }, + methods: { + checkFailureCount() { + if (this.glFeatures.jobLogJumpToFailures) { + backOff((next, stop) => { + this.failureCount = document.querySelectorAll('.term-fg-l-red').length; + + if (this.hasFailures || (this.isComplete && !this.hasFailures)) { + stop(); + } else { + next(); + } + }).catch(() => { + this.failureCount = null; + }); + } + }, + handleScrollToNextFailure() { + const failures = document.querySelectorAll('.term-fg-l-red'); + const nextFailure = failures[this.failureIndex]; + + if (nextFailure) { + nextFailure.scrollIntoView({ block: 'center' }); + this.failureIndex = (this.failureIndex + 1) % failures.length; + } + }, + handleScrollToTop() { + this.$emit('scrollJobLogTop'); + this.failureIndex = 0; + }, + handleScrollToBottom() { + this.$emit('scrollJobLogBottom'); + this.failureIndex = 0; + }, + searchJobLog() { + this.searchResults = []; + + if (!this.searchTerm) return; + + const compactedLog = compactJobLog(this.jobLog); + + compactedLog.forEach((line) => { + const lineText = line.content[0].text; + + if (lineText.toLocaleLowerCase().includes(this.searchTerm.toLocaleLowerCase())) { + this.searchResults.push(line); + } + }); + + if (this.searchResults.length > 0) { + this.$emit('searchResults', this.searchResults); + + // BE returns zero based index, we need to add one to match the line numbers in the DOM + const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`; + const logLine = document.querySelector(`.log-line ${firstSearchResult}`); + + if (logLine) { + setTimeout(() => scrollToElement(logLine)); + + const message = sprintf(s__('Job|%{searchLength} results found for %{searchTerm}'), { + searchLength: this.searchResults.length, + searchTerm: this.searchTerm, + }); + + this.$toast.show(message); + } else { + this.$toast.show(this.$options.i18n.logLineNumberNotFound); + } + } else { + this.$toast.show(this.$options.i18n.noResults); + } + }, + }, +}; +</script> +<template> + <div class="top-bar gl-display-flex gl-justify-content-space-between"> + <slot name="drawers"></slot> + <!-- truncate information --> + <div + class="truncated-info gl-display-none gl-sm-display-flex gl-flex-wrap gl-align-items-center" + data-testid="log-truncated-info" + > + <template v-if="isJobLogSizeVisible"> + {{ jobLogSize }} + <gl-link + v-if="rawPath" + :href="rawPath" + class="text-plain text-underline gl-ml-2" + data-testid="raw-link" + >{{ s__('Job|Complete Raw') }}</gl-link + > + </template> + </div> + <!-- eo truncate information --> + + <div class="controllers"> + <slot name="controllers"> </slot> + <gl-search-box-by-click + v-model="searchTerm" + class="gl-mr-3" + :placeholder="$options.i18n.searchPlaceholder" + data-testid="job-log-search-box" + @clear="$emit('searchResults', [])" + @submit="searchJobLog" + /> + + <help-popover class="gl-mr-3"> + <template #title>{{ $options.i18n.searchPopoverTitle }}</template> + + <p class="gl-mb-0"> + {{ $options.i18n.searchPopoverDescription }} + </p> + </help-popover> + + <!-- links --> + <gl-button + v-if="rawPath" + v-gl-tooltip.body + :title="$options.i18n.showRawButtonLabel" + :aria-label="$options.i18n.showRawButtonLabel" + :href="rawPath" + data-testid="job-raw-link-controller" + icon="doc-code" + /> + <!-- eo links --> + + <!-- scroll buttons --> + <gl-button + v-if="showJumpToFailures" + v-gl-tooltip + :title="$options.i18n.scrollToNextFailureButtonLabel" + :aria-label="$options.i18n.scrollToNextFailureButtonLabel" + :disabled="shouldDisableJumpToFailures" + class="btn-scroll gl-ml-3" + data-testid="job-controller-scroll-to-failure" + icon="soft-wrap" + @click="handleScrollToNextFailure" + /> + + <div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3"> + <gl-button + :disabled="isScrollTopDisabled" + class="btn-scroll" + data-testid="job-controller-scroll-top" + icon="scroll_up" + :aria-label="$options.i18n.scrollToTopButtonLabel" + @click="handleScrollToTop" + /> + </div> + + <div v-gl-tooltip :title="$options.i18n.scrollToBottomButtonLabel" class="gl-ml-3"> + <gl-button + :disabled="isScrollBottomDisabled" + class="js-scroll-bottom btn-scroll" + data-testid="job-controller-scroll-bottom" + icon="scroll_down" + :class="{ animate: isScrollingDown }" + :aria-label="$options.i18n.scrollToBottomButtonLabel" + @click="handleScrollToBottom" + /> + </div> + <!-- eo scroll buttons --> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue b/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue new file mode 100644 index 00000000000..39c612bc600 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue @@ -0,0 +1,71 @@ +<script> +import LogLine from './line.vue'; +import LogLineHeader from './line_header.vue'; + +export default { + name: 'CollapsibleLogSection', + components: { + LogLine, + LogLineHeader, + }, + props: { + section: { + type: Object, + required: true, + }, + jobLogEndpoint: { + type: String, + required: true, + }, + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + badgeDuration() { + return this.section.line && this.section.line.section_duration; + }, + highlightedLines() { + return this.searchResults.map((result) => result.lineNumber); + }, + headerIsHighlighted() { + const { + line: { lineNumber }, + } = this.section; + + return this.highlightedLines.includes(lineNumber); + }, + }, + methods: { + handleOnClickCollapsibleLine(section) { + this.$emit('onClickCollapsibleLine', section); + }, + lineIsHighlighted({ lineNumber }) { + return this.highlightedLines.includes(lineNumber); + }, + }, +}; +</script> +<template> + <div> + <log-line-header + :line="section.line" + :duration="badgeDuration" + :path="jobLogEndpoint" + :is-closed="section.isClosed" + :is-highlighted="headerIsHighlighted" + @toggleLine="handleOnClickCollapsibleLine(section)" + /> + <template v-if="!section.isClosed"> + <log-line + v-for="line in section.lines" + :key="line.offset" + :line="line" + :path="jobLogEndpoint" + :is-highlighted="lineIsHighlighted(line)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue b/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue new file mode 100644 index 00000000000..54b76fd9edd --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue @@ -0,0 +1,20 @@ +<script> +import { GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + }, + props: { + duration: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <gl-badge> + {{ duration }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/line.vue b/app/assets/javascripts/ci/job_details/components/log/line.vue new file mode 100644 index 00000000000..fa4a12b3dd3 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/line.vue @@ -0,0 +1,83 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import { getLocationHash } from '~/lib/utils/url_utility'; +import { linkRegex } from './utils'; +import LineNumber from './line_number.vue'; + +export default { + functional: true, + props: { + line: { + type: Object, + required: true, + }, + path: { + type: String, + required: true, + }, + isHighlighted: { + type: Boolean, + required: false, + default: false, + }, + }, + render(h, { props }) { + const { line, path, isHighlighted } = props; + + const chars = line.content.map((content) => { + return h( + 'span', + { + class: ['gl-white-space-pre-wrap', content.style], + }, + // Simple "tokenization": Split text in chunks of text + // which alternate between text and urls. + content.text.split(linkRegex).map((chunk) => { + // Return normal string for non-links + if (!chunk.match(linkRegex)) { + return chunk; + } + return h( + 'a', + { + attrs: { + href: chunk, + class: 'gl-reset-color! gl-text-decoration-underline', + rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings + }, + }, + chunk, + ); + }), + ); + }); + + let applyHashHighlight = false; + + if (window.location.hash) { + const hash = getLocationHash(); + const lineToMatch = `L${line.lineNumber + 1}`; + + if (hash === lineToMatch) { + applyHashHighlight = true; + } + } + + return h( + 'div', + { + class: ['js-line', 'log-line', { 'gl-bg-gray-700': isHighlighted || applyHashHighlight }], + }, + [ + h(LineNumber, { + props: { + lineNumber: line.lineNumber, + path, + }, + }), + ...chars, + ], + ); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/job_details/components/log/line_header.vue b/app/assets/javascripts/ci/job_details/components/log/line_header.vue new file mode 100644 index 00000000000..e647ab4ac0b --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/line_header.vue @@ -0,0 +1,81 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import DurationBadge from './duration_badge.vue'; +import LineNumber from './line_number.vue'; + +export default { + components: { + GlIcon, + LineNumber, + DurationBadge, + }, + props: { + line: { + type: Object, + required: true, + }, + isClosed: { + type: Boolean, + required: true, + }, + path: { + type: String, + required: true, + }, + duration: { + type: String, + required: false, + default: '', + }, + isHighlighted: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + applyHashHighlight: false, + }; + }, + computed: { + iconName() { + return this.isClosed ? 'chevron-lg-right' : 'chevron-lg-down'; + }, + }, + mounted() { + const hash = getLocationHash(); + const lineToMatch = `L${this.line.lineNumber + 1}`; + + if (hash === lineToMatch) { + this.applyHashHighlight = true; + } + }, + methods: { + handleOnClick() { + this.$emit('toggleLine'); + }, + }, +}; +</script> + +<template> + <div + class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start gl-relative" + :class="{ 'gl-bg-gray-700': isHighlighted || applyHashHighlight }" + role="button" + @click="handleOnClick" + > + <gl-icon :name="iconName" class="arrow gl-absolute gl-top-2" /> + <line-number :line-number="line.lineNumber" :path="path" /> + <span + v-for="(content, i) in line.content" + :key="i" + class="line-text w-100 gl-white-space-pre-wrap" + :class="content.style" + >{{ content.text }}</span + > + <duration-badge v-if="duration" :duration="duration" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/line_number.vue b/app/assets/javascripts/ci/job_details/components/log/line_number.vue new file mode 100644 index 00000000000..7ca9154d2fe --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/line_number.vue @@ -0,0 +1,34 @@ +<script> +export default { + functional: true, + props: { + lineNumber: { + type: Number, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + render(h, { props }) { + const { lineNumber, path } = props; + + const parsedLineNumber = lineNumber + 1; + const lineId = `L${parsedLineNumber}`; + const lineHref = `${path}#${lineId}`; + + return h( + 'a', + { + class: 'gl-link d-inline-block text-right line-number flex-shrink-0', + attrs: { + id: lineId, + href: lineHref, + }, + }, + parsedLineNumber, + ); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/job_details/components/log/log.vue b/app/assets/javascripts/ci/job_details/components/log/log.vue new file mode 100644 index 00000000000..fb6a6a58074 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/log.vue @@ -0,0 +1,106 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +// eslint-disable-next-line no-restricted-imports +import { mapState, mapActions } from 'vuex'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import CollapsibleLogSection from './collapsible_section.vue'; +import LogLine from './line.vue'; + +export default { + components: { + CollapsibleLogSection, + LogLine, + }, + props: { + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + ...mapState([ + 'jobLogEndpoint', + 'jobLog', + 'isJobLogComplete', + 'isScrolledToBottomBeforeReceivingJobLog', + ]), + highlightedLines() { + return this.searchResults.map((result) => result.lineNumber); + }, + }, + updated() { + this.$nextTick(() => { + if (!window.location.hash) { + this.handleScrollDown(); + } + }); + }, + mounted() { + if (window.location.hash) { + const lineNumber = getLocationHash(); + + this.unwatchJobLog = this.$watch('jobLog', async () => { + if (this.jobLog.length) { + await this.$nextTick(); + + const el = document.getElementById(lineNumber); + scrollToElement(el); + this.unwatchJobLog(); + } + }); + } + }, + methods: { + ...mapActions(['toggleCollapsibleLine', 'scrollBottom']), + handleOnClickCollapsibleLine(section) { + this.toggleCollapsibleLine(section); + }, + /** + * The job log is sent in HTML, which means we need to use `v-html` to render it + * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated + * in this case because it runs before `v-html` has finished running, since there's no + * Vue binding. + * In order to scroll the page down after `v-html` has finished, we need to use setTimeout + */ + handleScrollDown() { + if (this.isScrolledToBottomBeforeReceivingJobLog) { + setTimeout(() => { + this.scrollBottom(); + }, 0); + } + }, + isHighlighted({ lineNumber }) { + return this.highlightedLines.includes(lineNumber); + }, + }, +}; +</script> +<template> + <code class="job-log d-block" data-testid="job-log-content"> + <template v-for="(section, index) in jobLog"> + <collapsible-log-section + v-if="section.isHeader" + :key="`collapsible-${index}`" + :section="section" + :job-log-endpoint="jobLogEndpoint" + :search-results="searchResults" + @onClickCollapsibleLine="handleOnClickCollapsibleLine" + /> + <log-line + v-else + :key="section.offset" + :line="section" + :path="jobLogEndpoint" + :is-highlighted="isHighlighted(section)" + /> + </template> + + <div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3"> + <div class="dot"></div> + <div class="dot"></div> + <div class="dot"></div> + </div> + </code> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/utils.js b/app/assets/javascripts/ci/job_details/components/log/utils.js new file mode 100644 index 00000000000..1ccecf3eb53 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/utils.js @@ -0,0 +1,12 @@ +/** + * capture anything starting with http:// or https:// + * https?:\/\/ + * + * up until a disallowed character or whitespace + * [^"<>()\\^`{|}\s]+ + * + * and a disallowed character or whitespace, including non-ending chars .,:;!? + * [^"<>()\\^`{|}\s.,:;!?] + */ +export const linkRegex = /(https?:\/\/[^"<>()\\^`{|}\s]+[^"<>()\\^`{|}\s.,:;!?])/g; +export default { linkRegex }; diff --git a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue new file mode 100644 index 00000000000..1232ffffb57 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue @@ -0,0 +1,305 @@ +<script> +import { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlButton, + GlLink, + GlLoadingIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { cloneDeep, uniqueId } from 'lodash'; +import { fetchPolicies } from '~/lib/graphql'; +import { createAlert } from '~/alert'; +import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { JOB_GRAPHQL_ERRORS } from '~/ci/constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { s__ } from '~/locale'; +import { reportMessageToSentry } from '~/ci/utils'; +import GetJob from '../graphql/queries/get_job.query.graphql'; +import playJobWithVariablesMutation from '../graphql/mutations/job_play_with_variables.mutation.graphql'; +import retryJobWithVariablesMutation from '../graphql/mutations/job_retry_with_variables.mutation.graphql'; + +// This component is a port of ~/ci/job_details/components/legacy_manual_variables_form.vue +// It is meant to fetch/update the job information via GraphQL instead of REST API. + +export default { + name: 'ManualVariablesForm', + components: { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlButton, + GlLink, + GlLoadingIcon, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectPath'], + apollo: { + variables: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), + }; + }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + update(data) { + const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes); + return [...jobVariables.reverse(), ...this.variables]; + }, + error(error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); + reportMessageToSentry(this.$options.name, error, {}); + }, + }, + }, + props: { + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + }, + clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0 gl-m-0! gl-ml-3!'], + inputTypes: { + key: 'key', + value: 'value', + }, + i18n: { + cancel: s__('CiVariables|Cancel'), + removeInputs: s__('CiVariables|Remove inputs'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The variables specified in the configuration file and %{linkStart}CI/CD settings%{linkEnd} are used by default.', + ), + overrideNoteText: s__( + 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}', + ), + header: s__('CiVariables|Variables'), + keyLabel: s__('CiVariables|Key'), + keyPlaceholder: s__('CiVariables|Input variable key'), + runAgainButtonText: s__('CiVariables|Run job again'), + runButtonText: s__('CiVariables|Run job'), + valueLabel: s__('CiVariables|Value'), + valuePlaceholder: s__('CiVariables|Input variable value'), + }, + data() { + return { + job: {}, + variables: [ + { + id: uniqueId(), + key: '', + value: '', + }, + ], + runBtnDisabled: false, + }; + }, + computed: { + mutationVariables() { + return { + id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId), + variables: this.preparedVariables, + }; + }, + preparedVariables() { + return this.variables + .filter((variable) => variable.key !== '') + .map(({ key, value }) => ({ key, value })); + }, + runBtnText() { + return this.isRetryable + ? this.$options.i18n.runAgainButtonText + : this.$options.i18n.runButtonText; + }, + variableSettings() { + return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); + }, + }, + methods: { + async playJob() { + try { + const { data } = await this.$apollo.mutate({ + mutation: playJobWithVariablesMutation, + variables: this.mutationVariables, + }); + if (data.jobPlay?.errors?.length) { + createAlert({ message: data.jobPlay.errors[0] }); + } else { + this.navigateToJob(data.jobPlay?.job?.webPath); + } + } catch (error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText }); + reportMessageToSentry(this.$options.name, error, {}); + } + }, + async retryJob() { + try { + const { data } = await this.$apollo.mutate({ + mutation: retryJobWithVariablesMutation, + variables: this.mutationVariables, + }); + if (data.jobRetry?.errors?.length) { + createAlert({ message: data.jobRetry.errors[0] }); + } else { + this.navigateToJob(data.jobRetry?.job?.webPath); + } + } catch (error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText }); + reportMessageToSentry(this.$options.name, error, {}); + } + }, + addEmptyVariable() { + const lastVar = this.variables[this.variables.length - 1]; + + if (lastVar.key === '') { + return; + } + + this.variables.push({ + id: uniqueId(), + key: '', + value: '', + }); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + deleteVariable(id) { + this.variables.splice( + this.variables.findIndex((el) => el.id === id), + 1, + ); + }, + inputRef(type, id) { + return `${this.$options.inputTypes[type]}-${id}`; + }, + navigateToJob(path) { + redirectTo(path); // eslint-disable-line import/no-deprecated + }, + runJob() { + this.runBtnDisabled = true; + + if (this.isRetryable) { + this.retryJob(); + } else { + this.playJob(); + } + }, + }, +}; +</script> +<template> + <gl-loading-icon v-if="$apollo.queries.variables.loading" class="gl-mt-9" size="lg" /> + <div v-else class="row gl-justify-content-center"> + <div class="col-10"> + <label>{{ $options.i18n.header }}</label> + + <div + v-for="(variable, index) in variables" + :key="variable.id" + class="gl-display-flex gl-align-items-center gl-mb-5" + data-testid="ci-variable-row" + > + <gl-form-input-group class="gl-mr-4 gl-flex-grow-1"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.keyLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="inputRef('key', variable.id)" + v-model="variable.key" + :placeholder="$options.i18n.keyPlaceholder" + data-testid="ci-variable-key" + @change="addEmptyVariable" + /> + </gl-form-input-group> + + <gl-form-input-group class="gl-flex-grow-2"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.valueLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="inputRef('value', variable.id)" + v-model="variable.value" + :placeholder="$options.i18n.valuePlaceholder" + data-testid="ci-variable-value" + /> + </gl-form-input-group> + + <gl-button + v-if="canRemove(index)" + v-gl-tooltip + :aria-label="$options.i18n.removeInputs" + :title="$options.i18n.removeInputs" + :class="$options.clearBtnSharedClasses" + category="tertiary" + icon="remove" + data-testid="delete-variable-btn" + @click="deleteVariable(variable.id)" + /> + <!-- Placeholder button to keep the layout fixed --> + <gl-button + v-else + class="gl-opacity-0 gl-pointer-events-none" + :class="$options.clearBtnSharedClasses" + data-testid="delete-variable-btn-placeholder" + category="tertiary" + icon="remove" + /> + </div> + + <div class="gl-text-center gl-mt-5"> + <gl-sprintf :message="$options.i18n.formHelpText"> + <template #link="{ content }"> + <gl-link :href="variableSettings" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + <div class="gl-text-center gl-mt-3"> + <gl-sprintf :message="$options.i18n.overrideNoteText"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </div> + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-button + v-if="isRetryable" + class="gl-mt-5" + data-testid="cancel-btn" + @click="$emit('hideManualVariablesForm')" + >{{ $options.i18n.cancel }}</gl-button + > + <gl-button + class="gl-mt-5" + variant="confirm" + category="primary" + :disabled="runBtnDisabled" + data-testid="run-manual-job-btn" + @click="runJob" + > + {{ runBtnText }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue new file mode 100644 index 00000000000..4c81a9bd033 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue @@ -0,0 +1,120 @@ +<script> +import { GlButton, GlButtonGroup, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + i18n: { + jobArtifacts: s__('Job|Job artifacts'), + artifactsHelpText: s__( + 'Job|Job artifacts are files that are configured to be uploaded when a job finishes execution. Artifacts could be compiled files, unit tests or scanning reports, or any other files generated by a job.', + ), + expiredText: s__('Job|The artifacts were removed'), + willExpireText: s__('Job|The artifacts will be removed'), + lockedText: s__( + 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.', + ), + keepText: s__('Job|Keep'), + downloadText: s__('Job|Download'), + browseText: s__('Job|Browse'), + }, + artifactsHelpPath: helpPagePath('ci/jobs/job_artifacts'), + components: { + GlButton, + GlButtonGroup, + GlIcon, + GlLink, + GlPopover, + TimeagoTooltip, + }, + mixins: [timeagoMixin], + props: { + artifact: { + type: Object, + required: true, + }, + helpUrl: { + type: String, + required: true, + }, + }, + computed: { + isExpired() { + return this.artifact?.expired && !this.isLocked; + }, + isLocked() { + return this.artifact?.locked; + }, + // Only when the key is `false` we can render this block + willExpire() { + return this.artifact?.expired === false && !this.isLocked; + }, + }, +}; +</script> +<template> + <div> + <div class="title gl-font-weight-bold"> + <span class="gl-mr-2">{{ $options.i18n.jobArtifacts }}</span> + <gl-link :href="$options.artifactsHelpPath" data-testid="artifacts-help-link"> + <gl-icon id="artifacts-help" name="question-o" /> + </gl-link> + <gl-popover + target="artifacts-help" + :title="$options.i18n.jobArtifacts" + triggers="hover focus" + > + {{ $options.i18n.artifactsHelpText }} + </gl-popover> + </div> + <p + v-if="isExpired || willExpire" + class="build-detail-row" + data-testid="artifacts-remove-timeline" + > + <span v-if="isExpired">{{ $options.i18n.expiredText }}</span> + <span v-if="willExpire" data-testid="artifacts-unlocked-message-content"> + {{ $options.i18n.willExpireText }} + </span> + <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> + <gl-link + :href="helpUrl" + target="_blank" + rel="noopener noreferrer nofollow" + data-testid="artifact-expired-help-link" + > + <gl-icon name="question-o" /> + </gl-link> + </p> + <p v-else-if="isLocked" class="build-detail-row"> + <span data-testid="artifacts-locked-message-content"> + {{ $options.i18n.lockedText }} + </span> + </p> + <gl-button-group class="gl-display-flex gl-mt-3"> + <gl-button + v-if="artifact.keep_path" + :href="artifact.keep_path" + data-method="post" + data-testid="keep-artifacts" + >{{ $options.i18n.keepText }}</gl-button + > + <gl-button + v-if="artifact.download_path" + :href="artifact.download_path" + rel="nofollow" + data-testid="download-artifacts" + download + >{{ $options.i18n.downloadText }}</gl-button + > + <gl-button + v-if="artifact.browse_path" + :href="artifact.browse_path" + data-testid="browse-artifacts-button" + >{{ $options.i18n.browseText }}</gl-button + > + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue new file mode 100644 index 00000000000..95616a4c706 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue @@ -0,0 +1,54 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + components: { + ClipboardButton, + GlLink, + }, + props: { + commit: { + type: Object, + required: true, + }, + mergeRequest: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <div> + <p class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-gap-2 gl-mb-0"> + <span class="gl-display-flex gl-font-weight-bold">{{ __('Commit') }}</span> + + <gl-link + :href="commit.commit_path" + class="gl-text-blue-500! gl-font-monospace" + data-testid="commit-sha" + > + {{ commit.short_id }} + </gl-link> + + <clipboard-button + :text="commit.id" + :title="__('Copy commit SHA')" + category="tertiary" + size="small" + class="gl-align-self-center" + /> + + <span v-if="mergeRequest"> + {{ __('in') }} + <gl-link :href="mergeRequest.path" class="gl-text-blue-500!" data-testid="link-commit" + >!{{ mergeRequest.iid }}</gl-link + > + </span> + </p> + + <p class="gl-mb-0">{{ commit.title }}</p> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue new file mode 100644 index 00000000000..a87f4b8467e --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue @@ -0,0 +1,34 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + externalLinks: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <div> + <div class="title gl-font-weight-bold">{{ s__('Job|External links') }}</div> + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <li v-for="(externalLink, index) in externalLinks" :key="index"> + <gl-link + :href="externalLink.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="gl-text-blue-600!" + > + <gl-icon name="external-link" class="flex-shrink-0" /> + {{ externalLink.label }} + </gl-link> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue new file mode 100644 index 00000000000..8e87f118fa4 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue @@ -0,0 +1,77 @@ +<script> +import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; +import { sprintf } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +export default { + components: { + CiIcon, + GlIcon, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + isActive: { + type: Boolean, + required: true, + }, + }, + computed: { + tooltipText() { + const { name, status } = this.job; + const text = `${name} - ${status.tooltip}`; + + if (this.isDelayedJob) { + return sprintf(text, { remainingTime: this.remainingTime }); + } + + return text; + }, + jobName() { + return this.job.name ? this.job.name : this.job.id; + }, + classes() { + return { + 'retried gl-text-secondary': this.job.retried, + 'gl-font-weight-bold': this.isActive, + }; + }, + dataTestId() { + return this.isActive ? 'active-job' : null; + }, + }, +}; +</script> + +<template> + <div class="build-job gl-relative" :class="classes"> + <gl-link + v-gl-tooltip.left.viewport + :href="job.status.details_path" + :title="tooltipText" + class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" + :data-testid="dataTestId" + > + <gl-icon + v-if="isActive" + name="arrow-right" + class="icon-arrow-right gl-absolute gl-display-block" + :size="14" + /> + + <ci-icon :status="job.status" class="gl-mr-3" :size="14" /> + + <span class="gl-text-truncate gl-w-full">{{ jobName }}</span> + + <gl-icon v-if="job.retried" name="retry" class="gl-mr-4" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue new file mode 100644 index 00000000000..58e49c71830 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue @@ -0,0 +1,72 @@ +<script> +import { GlLink, GlModal } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + name: 'JobRetryForwardDeploymentModal', + components: { + GlLink, + GlModal, + }, + i18n: { + cancel: __('Cancel'), + info: s__( + `Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. + Retrying this job could result in overwriting the environment with the older source code.`, + ), + areYouSure: s__('Jobs|Are you sure you want to proceed?'), + moreInfo: __('More information'), + primaryText: __('Retry job'), + title: s__('Jobs|Are you sure you want to retry this job?'), + }, + inject: { + retryOutdatedJobDocsUrl: { + default: '', + }, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + }, + data() { + return { + primaryProps: { + text: this.$options.i18n.primaryText, + attributes: { + 'data-method': 'post', + 'data-testid': 'retry-button-modal', + href: this.href, + variant: 'danger', + }, + }, + cancelProps: { + text: this.$options.i18n.cancel, + attributes: { category: 'secondary', variant: 'default' }, + }, + }; + }, +}; +</script> + +<template> + <gl-modal + :action-cancel="cancelProps" + :action-primary="primaryProps" + :modal-id="modalId" + :title="$options.i18n.title" + > + <p> + {{ $options.i18n.info }} + <gl-link v-if="retryOutdatedJobDocsUrl" :href="retryOutdatedJobDocsUrl" target="_blank"> + {{ $options.i18n.moreInfo }} + </gl-link> + </p> + <p>{{ $options.i18n.areYouSure }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue new file mode 100644 index 00000000000..26676123dc3 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue @@ -0,0 +1,84 @@ +<script> +import { GlButton, GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapGetters } from 'vuex'; +import { s__ } from '~/locale'; + +export default { + name: 'JobSidebarRetryButton', + i18n: { + retryJobLabel: s__('Job|Retry'), + runAgainJobButtonLabel: s__('Job|Run again'), + updateVariables: s__('Job|Update CI/CD variables'), + }, + components: { + GlButton, + GlDisclosureDropdown, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + isManualJob: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters(['hasForwardDeploymentFailure']), + dropdownItems() { + return [ + { + text: this.$options.i18n.runAgainJobButtonLabel, + href: this.href, + extraAttrs: { + 'data-method': 'post', + }, + }, + { + text: this.$options.i18n.updateVariables, + action: () => this.$emit('updateVariablesClicked'), + }, + ]; + }, + }, +}; +</script> +<template> + <gl-button + v-if="hasForwardDeploymentFailure" + v-gl-modal="modalId" + :aria-label="$options.i18n.retryJobLabel" + category="primary" + variant="confirm" + icon="retry" + data-testid="retry-job-button" + /> + <gl-disclosure-dropdown + v-else-if="isManualJob" + icon="retry" + category="primary" + placement="right" + positioning-strategy="fixed" + variant="confirm" + :items="dropdownItems" + /> + <gl-button + v-else + :href="href" + :aria-label="$options.i18n.retryJobLabel" + category="primary" + variant="confirm" + icon="retry" + data-method="post" + data-testid="retry-job-link" + /> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue new file mode 100644 index 00000000000..18bd2593c2a --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue @@ -0,0 +1,36 @@ +<script> +import JobContainerItem from './job_container_item.vue'; + +export default { + components: { + JobContainerItem, + }, + + props: { + jobs: { + type: Array, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + }, + methods: { + isJobActive(currentJobId) { + return this.jobId === currentJobId; + }, + }, +}; +</script> +<template> + <div class="block builds-container"> + <b class="gl-display-flex gl-mb-2 gl-font-weight-semibold">{{ __('Related jobs') }}</b> + <job-container-item + v-for="job in jobs" + :key="job.id" + :job="job" + :is-active="isJobActive(job.id)" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue new file mode 100644 index 00000000000..7f2f4fc0331 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue @@ -0,0 +1,127 @@ +<script> +import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapGetters, mapState } from 'vuex'; +import { forwardDeploymentFailureModalId } from '~/ci/constants'; +import { filterAnnotations } from '~/ci/job_details/utils'; +import ArtifactsBlock from './artifacts_block.vue'; +import CommitBlock from './commit_block.vue'; +import ExternalLinksBlock from './external_links_block.vue'; +import JobsContainer from './jobs_container.vue'; +import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; +import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; +import SidebarHeader from './sidebar_header.vue'; +import StagesDropdown from './stages_dropdown.vue'; +import TriggerBlock from './trigger_block.vue'; + +export default { + name: 'JobSidebar', + forwardDeploymentFailureModalId, + components: { + ArtifactsBlock, + CommitBlock, + JobsContainer, + JobRetryForwardDeploymentModal, + JobSidebarDetailsContainer, + SidebarHeader, + StagesDropdown, + TriggerBlock, + ExternalLinksBlock, + }, + props: { + artifactHelpUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + ...mapGetters(['hasForwardDeploymentFailure']), + ...mapState(['job', 'stages', 'jobs', 'selectedStage']), + hasArtifact() { + // the artifact object will always have a locked property + return Object.keys(this.job.artifact).length > 1; + }, + hasExternalLinks() { + return this.externalLinks.length > 0; + }, + hasTriggers() { + return !isEmpty(this.job.trigger); + }, + commit() { + return this.job?.pipeline?.commit || {}; + }, + selectedStageData() { + return this.stages.find((val) => val.name === this.selectedStage); + }, + shouldShowJobRetryForwardDeploymentModal() { + return this.job.retry_path && this.hasForwardDeploymentFailure; + }, + externalLinks() { + return filterAnnotations(this.job.annotations, 'external_link'); + }, + }, + watch: { + job(value, oldValue) { + const hasNewStatus = value.status.text !== oldValue.status.text; + const isCurrentStage = value?.stage === this.selectedStage; + + if (hasNewStatus && isCurrentStage) { + this.fetchJobsForStage(this.selectedStageData); + } + }, + }, + methods: { + ...mapActions(['fetchJobsForStage']), + }, +}; +</script> +<template> + <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> + <div class="sidebar-container"> + <div class="blocks-container gl-p-4"> + <sidebar-header + class="block gl-pb-4! gl-mb-2" + :rest-job="job" + :job-id="job.id" + @updateVariables="$emit('updateVariables')" + /> + + <job-sidebar-details-container class="block gl-mb-2" /> + + <artifacts-block + v-if="hasArtifact" + class="block gl-mb-2" + :artifact="job.artifact" + :help-url="artifactHelpUrl" + /> + + <external-links-block + v-if="hasExternalLinks" + class="block gl-mb-2" + :external-links="externalLinks" + /> + + <trigger-block v-if="hasTriggers" class="block gl-mb-2" :trigger="job.trigger" /> + + <commit-block class="block gl-mb-2" :commit="commit" :merge-request="job.merge_request" /> + + <stages-dropdown + v-if="job.pipeline" + class="block gl-mb-2" + :pipeline="job.pipeline" + :selected-stage="selectedStage" + :stages="stages" + @requestSidebarStageDropdown="fetchJobsForStage" + /> + + <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" /> + </div> + </div> + <job-retry-forward-deployment-modal + v-if="shouldShowJobRetryForwardDeploymentModal" + :modal-id="$options.forwardDeploymentFailureModalId" + :href="job.retry_path" + /> + </aside> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue new file mode 100644 index 00000000000..5b1bf354fd4 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue @@ -0,0 +1,64 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + name: 'SidebarDetailRow', + components: { + GlIcon, + GlLink, + }, + props: { + title: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: true, + }, + helpUrl: { + type: String, + required: false, + default: '', + }, + path: { + type: String, + required: false, + default: '', + }, + }, + computed: { + hasTitle() { + return this.title.length > 0; + }, + hasHelpURL() { + return this.helpUrl.length > 0; + }, + }, +}; +</script> +<template> + <p class="build-sidebar-item gl-mb-2"> + <b v-if="hasTitle" class="gl-display-flex">{{ title }}:</b> + <gl-link + v-if="path" + :href="path" + class="gl-text-blue-600!" + data-testid="job-sidebar-value-link" + > + {{ value }} + </gl-link> + <span v-else + >{{ value }} + <gl-link + v-if="hasHelpURL" + :href="helpUrl" + target="_blank" + data-testid="job-sidebar-help-link" + > + <gl-icon name="question-o" class="gl-ml-2 gl-text-blue-500" /> + </gl-link> + </span> + </p> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue new file mode 100644 index 00000000000..77e3ecb9b3c --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue @@ -0,0 +1,168 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapActions } from 'vuex'; +import { createAlert } from '~/alert'; +import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_GRAPHQL_ERRORS, forwardDeploymentFailureModalId, PASSED_STATUS } from '~/ci/constants'; +import GetJob from '../../graphql/queries/get_job.query.graphql'; +import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; + +export default { + name: 'SidebarHeader', + i18n: { + cancelJobButtonLabel: s__('Job|Cancel'), + debug: __('Debug'), + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), + eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), + newIssue: __('New issue'), + retryJobLabel: s__('Job|Retry'), + toggleSidebar: __('Toggle Sidebar'), + runAgainJobButtonLabel: s__('Job|Run again'), + }, + forwardDeploymentFailureModalId, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + JobSidebarRetryButton, + TooltipOnTruncate, + }, + inject: ['projectPath'], + apollo: { + job: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), + }; + }, + update(data) { + const { name, manualJob } = data?.project?.job || {}; + return { + name, + manualJob, + }; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); + }, + }, + }, + props: { + jobId: { + type: Number, + required: true, + }, + restJob: { + type: Object, + required: true, + default: () => ({}), + }, + }, + data() { + return { + job: {}, + }; + }, + computed: { + buttonTitle() { + return this.restJob.status?.text === PASSED_STATUS + ? this.$options.i18n.runAgainJobButtonLabel + : this.$options.i18n.retryJobLabel; + }, + canShowJobRetryButton() { + return this.restJob.retry_path && !this.$apollo.queries.job.loading; + }, + isManualJob() { + return this.job?.manualJob; + }, + retryButtonCategory() { + return this.restJob.status && this.restJob.recoverable ? 'primary' : 'secondary'; + }, + }, + methods: { + ...mapActions(['toggleSidebar']), + }, +}; +</script> + +<template> + <div> + <tooltip-on-truncate :title="job.name" truncate-target="child" + ><h4 class="gl-mt-0 gl-mb-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> + </tooltip-on-truncate> + <div class="gl-display-flex gl-gap-3"> + <gl-button + v-if="restJob.erase_path" + v-gl-tooltip.bottom + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="restJob.erase_path" + :data-confirm="$options.i18n.eraseLogConfirmText" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> + <gl-button + v-if="restJob.new_issue_path" + v-gl-tooltip.bottom + :href="restJob.new_issue_path" + :title="$options.i18n.newIssue" + :aria-label="$options.i18n.newIssue" + category="secondary" + variant="confirm" + data-testid="job-new-issue" + icon="issue-new" + /> + <gl-button + v-if="restJob.terminal_path" + v-gl-tooltip.bottom + :href="restJob.terminal_path" + :title="$options.i18n.debug" + :aria-label="$options.i18n.debug" + target="_blank" + icon="external-link" + data-testid="terminal-link" + /> + <job-sidebar-retry-button + v-if="canShowJobRetryButton" + v-gl-tooltip.bottom + :title="buttonTitle" + :aria-label="buttonTitle" + :is-manual-job="isManualJob" + :category="retryButtonCategory" + :href="restJob.retry_path" + :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" + data-testid="retry-button" + @updateVariablesClicked="$emit('updateVariables')" + /> + <gl-button + v-if="restJob.cancel_path" + v-gl-tooltip.bottom + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" + :href="restJob.cancel_path" + variant="danger" + icon="cancel" + data-method="post" + data-testid="cancel-button" + rel="nofollow" + /> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + category="secondary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue new file mode 100644 index 00000000000..ebef3ecaa3f --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue @@ -0,0 +1,130 @@ +<script> +// eslint-disable-next-line no-restricted-imports +import { mapState } from 'vuex'; +import { GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import DetailRow from './sidebar_detail_row.vue'; + +export default { + name: 'JobSidebarDetailsContainer', + components: { + DetailRow, + GlBadge, + }, + mixins: [timeagoMixin], + computed: { + ...mapState(['job']), + coverage() { + return `${this.job.coverage}%`; + }, + duration() { + return timeIntervalInWords(this.job.duration); + }, + durationTitle() { + return this.job.finished_at ? __('Duration') : __('Elapsed time'); + }, + erasedAt() { + return this.timeFormatted(this.job.erased_at); + }, + finishedAt() { + return this.timeFormatted(this.job.finished_at); + }, + hasTags() { + return this.job?.tags?.length; + }, + hasTimeout() { + return this.job?.metadata?.timeout_human_readable ?? false; + }, + hasAnyDetail() { + return Boolean( + this.job.duration || + this.job.finished_at || + this.job.erased_at || + this.job.queued_duration || + this.job.id || + this.job.runner || + this.job.coverage, + ); + }, + jobId() { + return this.job?.id ? `#${this.job.id}` : ''; + }, + runnerId() { + const { id, short_sha: token, description } = this.job.runner; + + return `#${id} (${token}) ${description}`; + }, + queuedDuration() { + return timeIntervalInWords(this.job.queued_duration); + }, + shouldRenderBlock() { + return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags); + }, + timeout() { + return `${this.job?.metadata?.timeout_human_readable}${this.timeoutSource}`; + }, + timeoutSource() { + if (!this.job?.metadata?.timeout_source) { + return ''; + } + + return sprintf(__(' (from %{timeoutSource})'), { + timeoutSource: this.job.metadata.timeout_source, + }); + }, + runnerAdminPath() { + return this.job?.runner?.admin_path || ''; + }, + }, + i18n: { + COVERAGE: __('Coverage'), + FINISHED: __('Finished'), + ERASED: __('Erased'), + QUEUED: __('Queued'), + RUNNER: __('Runner'), + TAGS: __('Tags'), + TIMEOUT: __('Timeout'), + ID: __('Job ID'), + }, + TIMEOUT_HELP_URL: helpPagePath('/ci/pipelines/settings.md', { + anchor: 'set-a-limit-for-how-long-jobs-can-run', + }), +}; +</script> + +<template> + <div v-if="shouldRenderBlock"> + <detail-row v-if="job.duration" :value="duration" :title="durationTitle" /> + <detail-row + v-if="job.finished_at" + :value="finishedAt" + data-testid="job-finished" + :title="$options.i18n.FINISHED" + /> + <detail-row v-if="job.erased_at" :value="erasedAt" :title="$options.i18n.ERASED" /> + <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" /> + <detail-row + v-if="hasTimeout" + :help-url="$options.TIMEOUT_HELP_URL" + :value="timeout" + data-testid="job-timeout" + :title="$options.i18n.TIMEOUT" + /> + <detail-row v-if="job.id" :value="jobId" :title="$options.i18n.ID" /> + <detail-row + v-if="job.runner" + :value="runnerId" + :title="$options.i18n.RUNNER" + :path="runnerAdminPath" + /> + <detail-row v-if="job.coverage" :value="coverage" :title="$options.i18n.COVERAGE" /> + + <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> + <span class="font-weight-bold">{{ $options.i18n.TAGS }}:</span> + <gl-badge v-for="(tag, i) in job.tags" :key="i" variant="info" size="sm">{{ tag }}</gl-badge> + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue new file mode 100644 index 00000000000..7744395734f --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue @@ -0,0 +1,179 @@ +<script> +import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import { Mousetrap } from '~/lib/mousetrap'; +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; +import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings'; + +export default { + components: { + ClipboardButton, + GlDisclosureDropdown, + GlLink, + GlSprintf, + CiBadgeLink, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + stages: { + type: Array, + required: true, + }, + selectedStage: { + type: String, + required: true, + }, + }, + computed: { + dropdownItems() { + return this.stages.map((stage) => ({ + text: stage.name, + action: () => { + this.onStageClick(stage); + }, + })); + }, + + hasRef() { + return !isEmpty(this.pipeline.ref); + }, + isTriggeredByMergeRequest() { + return Boolean(this.pipeline.merge_request); + }, + isMergeRequestPipeline() { + return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); + }, + pipelineInfo() { + if (!this.hasRef) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status}'); + } + if (!this.isTriggeredByMergeRequest) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status} for %{ref}'); + } + if (!this.isMergeRequestPipeline) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status} for %{mrId} with %{source}'); + } + + return s__( + 'Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source} into %{target}', + ); + }, + }, + mounted() { + Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), this.handleKeyboardCopy); + }, + beforeDestroy() { + Mousetrap.unbind(keysFor(MR_COPY_SOURCE_BRANCH_NAME)); + }, + methods: { + onStageClick(stage) { + this.$emit('requestSidebarStageDropdown', stage); + }, + handleKeyboardCopy() { + let button; + + if (!this.hasRef) { + return; + } + if (!this.isTriggeredByMergeRequest) { + button = this.$refs['copy-source-ref-link']; + } else { + button = this.$refs['copy-source-branch-link']; + } + + clickCopyToClipboardButton(button.$el); + }, + }, +}; +</script> +<template> + <div class="dropdown"> + <div class="gl-display-flex gl-flex-wrap gl-gap-2 js-pipeline-info" data-testid="pipeline-info"> + <gl-sprintf :message="pipelineInfo"> + <template #bold="{ content }"> + <span class="gl-display-flex gl-font-weight-bold">{{ content }}</span> + </template> + <template #id> + <gl-link + :href="pipeline.path" + class="js-pipeline-path link-commit gl-text-blue-500!" + data-testid="pipeline-path" + >#{{ pipeline.id }}</gl-link + > + </template> + <template #status> + <ci-badge-link + :status="pipeline.details.status" + size="sm" + data-testid="pipeline-status-link" + /> + </template> + <template #mrId> + <gl-link + :href="pipeline.merge_request.path" + class="link-commit gl-text-blue-500!" + data-testid="mr-link" + >!{{ pipeline.merge_request.iid }}</gl-link + > + </template> + <template #ref> + <gl-link + :href="pipeline.ref.path" + class="link-commit ref-name gl-mt-1" + data-testid="source-ref-link" + >{{ pipeline.ref.name }}</gl-link + ><clipboard-button + ref="copy-source-ref-link" + :text="pipeline.ref.name" + :title="__('Copy reference')" + category="tertiary" + size="small" + data-testid="copy-source-ref-link" + /> + </template> + <template #source> + <gl-link + :href="pipeline.merge_request.source_branch_path" + class="link-commit ref-name gl-mt-1" + data-testid="source-branch-link" + >{{ pipeline.merge_request.source_branch }}</gl-link + ><clipboard-button + ref="copy-source-branch-link" + :text="pipeline.merge_request.source_branch" + :title="__('Copy branch name')" + category="tertiary" + size="small" + data-testid="copy-source-branch-link" + /> + </template> + <template #target> + <gl-link + :href="pipeline.merge_request.target_branch_path" + class="link-commit ref-name gl-mt-1" + data-testid="target-branch-link" + >{{ pipeline.merge_request.target_branch }}</gl-link + ><clipboard-button + :text="pipeline.merge_request.target_branch" + :title="__('Copy branch name')" + category="tertiary" + size="small" + data-testid="copy-target-branch-link" + /> + </template> + </gl-sprintf> + </div> + + <gl-disclosure-dropdown + :toggle-text="selectedStage" + :items="dropdownItems" + block + class="gl-mt-2" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue new file mode 100644 index 00000000000..315587a3376 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue @@ -0,0 +1,94 @@ +<script> +import { GlButton, GlTableLite } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const DEFAULT_TD_CLASSES = 'gl-font-sm!'; + +export default { + fields: [ + { + key: 'key', + label: __('Key'), + tdAttr: { 'data-testid': 'trigger-build-key' }, + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'value', + label: __('Value'), + tdAttr: { 'data-testid': 'trigger-build-value' }, + tdClass: DEFAULT_TD_CLASSES, + }, + ], + components: { + GlButton, + GlTableLite, + }, + props: { + trigger: { + type: Object, + required: true, + }, + }, + data() { + return { + showVariableValues: false, + }; + }, + computed: { + hasVariables() { + return this.trigger.variables.length > 0; + }, + getToggleButtonText() { + return this.showVariableValues ? __('Hide values') : __('Reveal values'); + }, + hasValues() { + return this.trigger.variables.some((v) => v.value); + }, + }, + methods: { + toggleValues() { + this.showVariableValues = !this.showVariableValues; + }, + getDisplayValue(value) { + return this.showVariableValues ? value : '••••••'; + }, + }, +}; +</script> + +<template> + <div> + <p + v-if="trigger.short_token" + :class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }" + data-testid="trigger-short-token" + > + <span class="gl-font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} + </p> + + <template v-if="hasVariables"> + <p class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <span class="gl-display-flex gl-font-weight-bold">{{ __('Trigger variables') }}</span> + + <gl-button + v-if="hasValues" + class="gl-mt-2" + size="small" + data-testid="trigger-reveal-values-button" + @click="toggleValues" + >{{ getToggleButtonText }}</gl-button + > + </p> + + <gl-table-lite :items="trigger.variables" :fields="$options.fields" small bordered fixed> + <template #cell(key)="{ item }"> + <span class="gl-overflow-break-word">{{ item.key }}</span> + </template> + + <template #cell(value)="data"> + <span class="gl-overflow-break-word">{{ getDisplayValue(data.value) }}</span> + </template> + </gl-table-lite> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/stuck_block.vue b/app/assets/javascripts/ci/job_details/components/stuck_block.vue new file mode 100644 index 00000000000..8c73f09daea --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/stuck_block.vue @@ -0,0 +1,91 @@ +<script> +import { GlAlert, GlBadge, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; +/** + * Renders Stuck Runners block for job's view. + */ +export default { + components: { + GlAlert, + GlBadge, + GlLink, + GlSprintf, + }, + props: { + hasOfflineRunnersForProject: { + type: Boolean, + required: true, + }, + tags: { + type: Array, + required: false, + default: () => [], + }, + runnersPath: { + type: String, + required: true, + }, + }, + computed: { + hasNoRunnersWithCorrespondingTags() { + return this.tags.length > 0; + }, + protectedBranchSettingsDocsLink() { + return `${DOCS_URL}/runner/security/index.html#reduce-the-security-risk-of-using-privileged-containers`; + }, + stuckData() { + if (this.hasNoRunnersWithCorrespondingTags) { + return { + text: s__( + `Job|This job is stuck because of one of the following problems. There are no active runners online, no runners for the %{linkStart}protected branch%{linkEnd}, or no runners that match all of the job's tags:`, + ), + dataTestId: 'job-stuck-with-tags', + showTags: true, + }; + } + if (this.hasOfflineRunnersForProject) { + return { + text: s__(`Job|This job is stuck because the project + doesn't have any runners online assigned to it.`), + dataTestId: 'job-stuck-no-runners', + showTags: false, + }; + } + + return { + text: s__(`Job|This job is stuck because you don't + have any active runners that can run this job.`), + dataTestId: 'job-stuck-no-active-runners', + showTags: false, + }; + }, + }, +}; +</script> +<template> + <gl-alert variant="warning" :dismissible="false"> + <p class="gl-mb-0" :data-testid="stuckData.dataTestId"> + <gl-sprintf :message="stuckData.text"> + <template #link="{ content }"> + <a + class="gl-display-inline-block" + :href="protectedBranchSettingsDocsLink" + target="_blank" + > + {{ content }} + </a> + </template> + </gl-sprintf> + <template v-if="stuckData.showTags"> + <gl-badge v-for="tag in tags" :key="tag" variant="info"> + {{ tag }} + </gl-badge> + </template> + </p> + {{ __('Go to project') }} + <gl-link v-if="runnersPath" :href="runnersPath"> + {{ __('CI settings') }} + </gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue b/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue new file mode 100644 index 00000000000..c9747ca9f02 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue @@ -0,0 +1,33 @@ +<script> +import { GlLink, GlAlert } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +/** + * Renders Unmet Prerequisites block for job's view. + */ +export default { + i18n: { + failMessage: s__( + 'Job|This job failed because the necessary resources were not successfully created.', + ), + moreInformation: __('More information'), + }, + components: { + GlLink, + GlAlert, + }, + props: { + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <gl-alert variant="danger" class="gl-mt-3" :dismissible="false"> + {{ $options.i18n.failMessage }} + <gl-link :href="helpPath"> + {{ $options.i18n.moreInformation }} + </gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql new file mode 100644 index 00000000000..7fb887b2dd4 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql @@ -0,0 +1,11 @@ +#import "~/ci/job_details/graphql/fragments/ci_variable.fragment.graphql" + +fragment BaseCiJob on CiJob { + id + manualVariables { + nodes { + ...ManualCiVariable + } + } + __typename +} diff --git a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql new file mode 100644 index 00000000000..0479df7bc4c --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql @@ -0,0 +1,6 @@ +fragment ManualCiVariable on CiVariable { + __typename + id + key + value +} diff --git a/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql b/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql new file mode 100644 index 00000000000..5d8a7b4c6f6 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql @@ -0,0 +1,11 @@ +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" + +mutation playJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { + jobPlay(input: { id: $id, variables: $variables }) { + job { + ...BaseCiJob + webPath + } + errors + } +} diff --git a/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql new file mode 100644 index 00000000000..cd66a30ce63 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql @@ -0,0 +1,11 @@ +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" + +mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { + jobRetry(input: { id: $id, variables: $variables }) { + job { + ...BaseCiJob + webPath + } + errors + } +} diff --git a/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql b/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql new file mode 100644 index 00000000000..a521ec2bb72 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql @@ -0,0 +1,12 @@ +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" + +query getJob($fullPath: ID!, $id: JobID!) { + project(fullPath: $fullPath) { + id + job(id: $id) { + ...BaseCiJob + manualJob + name + } + } +} diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js new file mode 100644 index 00000000000..5a1ecf2fff3 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/index.js @@ -0,0 +1,69 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import JobApp from './job_app.vue'; +import createStore from './store'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +const initializeJobPage = (element) => { + const store = createStore(); + + // Let's start initializing the store (i.e. fetching data) right away + store.dispatch('init', element.dataset); + + const { + artifactHelpUrl, + deploymentHelpUrl, + runnerSettingsUrl, + subscriptionsMoreMinutesUrl, + endpoint, + pagePath, + logState, + buildStatus, + projectPath, + retryOutdatedJobDocsUrl, + aiRootCauseAnalysisAvailable, + } = element.dataset; + + return new Vue({ + el: element, + apolloProvider, + store, + components: { + JobApp, + }, + provide: { + projectPath, + retryOutdatedJobDocsUrl, + aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable), + }, + render(createElement) { + return createElement('job-app', { + props: { + artifactHelpUrl, + deploymentHelpUrl, + runnerSettingsUrl, + subscriptionsMoreMinutesUrl, + endpoint, + pagePath, + logState, + buildStatus, + projectPath, + }, + }); + }, + }); +}; + +export default () => { + const jobElement = document.getElementById('js-job-page'); + initializeJobPage(jobElement); +}; diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue new file mode 100644 index 00000000000..5137ebfeaa8 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -0,0 +1,349 @@ +<script> +import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { throttle, isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapGetters, mapState, mapActions } from 'vuex'; +import LogTopBar from 'ee_else_ce/ci/job_details/components/job_log_controllers.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; +import { __, sprintf } from '~/locale'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; +import Log from '~/ci/job_details/components/log/log.vue'; +import { MANUAL_STATUS } from '~/ci/constants'; +import EmptyState from './components/empty_state.vue'; +import EnvironmentsBlock from './components/environments_block.vue'; +import ErasedBlock from './components/erased_block.vue'; +import JobHeader from './components/job_header.vue'; +import StuckBlock from './components/stuck_block.vue'; +import UnmetPrerequisitesBlock from './components/unmet_prerequisites_block.vue'; +import Sidebar from './components/sidebar/sidebar.vue'; + +export default { + name: 'JobPageApp', + components: { + JobHeader, + EmptyState, + EnvironmentsBlock, + ErasedBlock, + GlIcon, + Log, + LogTopBar, + StuckBlock, + UnmetPrerequisitesBlock, + Sidebar, + GlLoadingIcon, + SharedRunner: () => import('ee_component/ci/runner/components/shared_runner_limit_block.vue'), + GlAlert, + }, + directives: { + SafeHtml, + }, + mixins: [delayedJobMixin], + props: { + artifactHelpUrl: { + type: String, + required: false, + default: '', + }, + runnerSettingsUrl: { + type: String, + required: false, + default: null, + }, + deploymentHelpUrl: { + type: String, + required: false, + default: null, + }, + terminalPath: { + type: String, + required: false, + default: null, + }, + projectPath: { + type: String, + required: true, + }, + subscriptionsMoreMinutesUrl: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + searchResults: [], + showUpdateVariablesState: false, + }; + }, + computed: { + ...mapState([ + 'isLoading', + 'job', + 'isSidebarOpen', + 'jobLog', + 'isJobLogComplete', + 'jobLogSize', + 'isJobLogSizeVisible', + 'isScrollBottomDisabled', + 'isScrollTopDisabled', + 'isScrolledToBottomBeforeReceivingJobLog', + 'hasError', + 'selectedStage', + ]), + ...mapGetters([ + 'headerTime', + 'hasUnmetPrerequisitesFailure', + 'shouldRenderCalloutMessage', + 'shouldRenderTriggeredLabel', + 'hasEnvironment', + 'shouldRenderSharedRunnerLimitWarning', + 'hasJobLog', + 'emptyStateIllustration', + 'isScrollingDown', + 'emptyStateAction', + 'hasOfflineRunnersForProject', + ]), + + shouldRenderContent() { + return !this.isLoading && !this.hasError; + }, + + emptyStateTitle() { + const { emptyStateIllustration, remainingTime } = this; + const { title } = emptyStateIllustration; + + if (this.isDelayedJob) { + return sprintf(title, { remainingTime }); + } + + return title; + }, + + shouldRenderHeaderCallout() { + return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; + }, + + isJobRetryable() { + return Boolean(this.job.retry_path); + }, + + jobName() { + return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); + }, + }, + watch: { + // Once the job log is loaded, + // fetch the stages for the dropdown on the sidebar + job(newVal, oldVal) { + if (isEmpty(oldVal) && !isEmpty(newVal.pipeline)) { + const stages = this.job.pipeline.details.stages || []; + + const defaultStage = stages.find((stage) => stage && stage.name === this.selectedStage); + + if (defaultStage) { + this.fetchJobsForStage(defaultStage); + } + } + + // Only poll for job log if we are not in the manual variables form empty state. + // This will be handled more elegantly in the future with GraphQL in https://gitlab.com/gitlab-org/gitlab/-/issues/389597 + if (newVal?.status?.group !== MANUAL_STATUS && !this.showUpdateVariablesState) { + this.fetchJobLog(); + } + }, + }, + created() { + this.throttled = throttle(this.toggleScrollButtons, 100); + + window.addEventListener('resize', this.onResize); + window.addEventListener('scroll', this.updateScroll); + }, + mounted() { + this.updateSidebar(); + }, + beforeDestroy() { + this.stopPollingJobLog(); + this.stopPolling(); + window.removeEventListener('resize', this.onResize); + window.removeEventListener('scroll', this.updateScroll); + }, + methods: { + ...mapActions([ + 'fetchJobLog', + 'fetchJobsForStage', + 'hideSidebar', + 'showSidebar', + 'toggleSidebar', + 'scrollBottom', + 'scrollTop', + 'stopPollingJobLog', + 'stopPolling', + 'toggleScrollButtons', + 'toggleScrollAnimation', + ]), + onHideManualVariablesForm() { + this.showUpdateVariablesState = false; + }, + onResize() { + this.updateSidebar(); + this.updateScroll(); + }, + onUpdateVariables() { + this.showUpdateVariablesState = true; + }, + updateSidebar() { + const breakpoint = bp.getBreakpointSize(); + if (breakpoint === 'xs' || breakpoint === 'sm') { + this.hideSidebar(); + } else if (!this.isSidebarOpen) { + this.showSidebar(); + } + }, + updateScroll() { + if (!isScrolledToBottom()) { + this.toggleScrollAnimation(false); + } else if (this.isScrollingDown) { + this.toggleScrollAnimation(true); + } + + this.throttled(); + }, + setSearchResults(searchResults) { + this.searchResults = searchResults; + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-6" /> + + <template v-else-if="shouldRenderContent"> + <div class="build-page" data-testid="job-content"> + <!-- Header Section --> + <header> + <div class="build-header top-area"> + <job-header + :status="job.status" + :time="headerTime" + :user="job.user" + :should-render-triggered-label="shouldRenderTriggeredLabel" + :name="jobName" + @clickedSidebarButton="toggleSidebar" + /> + </div> + <gl-alert + v-if="shouldRenderHeaderCallout" + variant="danger" + class="gl-mt-3" + :dismissible="false" + > + <div v-safe-html="job.callout_message"></div> + </gl-alert> + </header> + <!-- EO Header Section --> + + <!-- Body Section --> + <stuck-block + v-if="job.stuck" + :has-offline-runners-for-project="hasOfflineRunnersForProject" + :tags="job.tags" + :runners-path="runnerSettingsUrl" + /> + + <unmet-prerequisites-block + v-if="hasUnmetPrerequisitesFailure" + :help-path="deploymentHelpUrl" + /> + + <shared-runner + v-if="shouldRenderSharedRunnerLimitWarning" + :quota-used="job.runners.quota.used" + :quota-limit="job.runners.quota.limit" + :project-path="projectPath" + :subscriptions-more-minutes-url="subscriptionsMoreMinutesUrl" + /> + + <environments-block + v-if="hasEnvironment" + :deployment-status="job.deployment_status" + :deployment-cluster="job.deployment_cluster" + :icon-status="job.status" + /> + + <erased-block + v-if="job.erased_at" + data-testid="job-erased-block" + :user="job.erased_by" + :erased-at="job.erased_at" + /> + + <div + v-if="job.archived" + class="gl-mt-3 gl-py-2 gl-px-3 gl-align-items-center gl-z-index-1 gl-m-auto archived-job" + :class="{ 'sticky-top gl-border-bottom-0': hasJobLog }" + data-testid="archived-job" + > + <gl-icon name="lock" class="gl-vertical-align-bottom" /> + {{ __('This job is archived. Only the complete pipeline can be retried.') }} + </div> + <!-- job log --> + <div + v-if="hasJobLog && !showUpdateVariablesState" + class="build-log-container gl-relative" + :class="{ 'gl-mt-3': !job.archived }" + > + <log-top-bar + :class="{ + 'has-archived-block': job.archived, + }" + :size="jobLogSize" + :raw-path="job.raw_path" + :is-scroll-bottom-disabled="isScrollBottomDisabled" + :is-scroll-top-disabled="isScrollTopDisabled" + :is-job-log-size-visible="isJobLogSizeVisible" + :is-scrolling-down="isScrollingDown" + :is-complete="isJobLogComplete" + :job-log="jobLog" + @scrollJobLogTop="scrollTop" + @scrollJobLogBottom="scrollBottom" + @searchResults="setSearchResults" + /> + <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" /> + </div> + <!-- EO job log --> + + <!-- empty state --> + <empty-state + v-if="!hasJobLog || showUpdateVariablesState" + :illustration-path="emptyStateIllustration.image" + :illustration-size-class="emptyStateIllustration.size" + :is-retryable="isJobRetryable" + :job-id="job.id" + :title="emptyStateTitle" + :content="emptyStateIllustration.content" + :action="emptyStateAction" + :playable="job.playable" + :scheduled="job.scheduled" + @hideManualVariablesForm="onHideManualVariablesForm()" + /> + <!-- EO empty state --> + + <!-- EO Body Section --> + </div> + </template> + + <sidebar + v-if="shouldRenderContent" + :class="{ + 'right-sidebar-expanded': isSidebarOpen, + 'right-sidebar-collapsed': !isSidebarOpen, + }" + :artifact-help-url="artifactHelpUrl" + data-testid="job-sidebar" + @updateVariables="onUpdateVariables()" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/store/actions.js b/app/assets/javascripts/ci/job_details/store/actions.js new file mode 100644 index 00000000000..33d83689e61 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/actions.js @@ -0,0 +1,277 @@ +import Visibility from 'visibilityjs'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon'; +import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; +import Poll from '~/lib/utils/poll'; +import { + canScroll, + isScrolledToBottom, + isScrolledToTop, + scrollDown, + scrollUp, +} from '~/lib/utils/scroll_utils'; +import { __ } from '~/locale'; +import { reportToSentry } from '~/ci/utils'; +import * as types from './mutation_types'; + +export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { + dispatch('setJobEndpoint', endpoint); + dispatch('setJobLogOptions', { + logState, + pagePath, + }); + + return dispatch('fetchJob'); +}; + +export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); +export const setJobLogOptions = ({ commit }, options) => commit(types.SET_JOB_LOG_OPTIONS, options); + +export const hideSidebar = ({ commit }) => commit(types.HIDE_SIDEBAR); +export const showSidebar = ({ commit }) => commit(types.SHOW_SIDEBAR); + +export const toggleSidebar = ({ dispatch, state }) => { + if (state.isSidebarOpen) { + dispatch('hideSidebar'); + } else { + dispatch('showSidebar'); + } +}; + +let eTagPoll; + +export const clearEtagPoll = () => { + eTagPoll = null; +}; + +export const stopPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; + +export const restartPolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; + +export const requestJob = ({ commit }) => commit(types.REQUEST_JOB); + +export const fetchJob = ({ state, dispatch }) => { + dispatch('requestJob'); + + eTagPoll = new Poll({ + resource: { + getJob(endpoint) { + return axios.get(endpoint); + }, + }, + data: state.jobEndpoint, + method: 'getJob', + successCallback: ({ data }) => dispatch('receiveJobSuccess', data), + errorCallback: () => dispatch('receiveJobError'), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } else { + axios + .get(state.jobEndpoint) + .then(({ data }) => dispatch('receiveJobSuccess', data)) + .catch(() => dispatch('receiveJobError')); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + dispatch('restartPolling'); + } else { + dispatch('stopPolling'); + } + }); +}; + +export const receiveJobSuccess = ({ commit }, data = {}) => { + commit(types.RECEIVE_JOB_SUCCESS, data); + + if (data.status && data.status.favicon) { + setFaviconOverlay(data.status.favicon); + } else { + resetFavicon(); + } +}; +export const receiveJobError = ({ commit }) => { + commit(types.RECEIVE_JOB_ERROR); + createAlert({ + message: __('An error occurred while fetching the job.'), + }); + resetFavicon(); +}; + +/** + * Job Log + */ +export const scrollTop = ({ dispatch }) => { + scrollUp(); + dispatch('toggleScrollButtons'); +}; + +export const scrollBottom = ({ dispatch }) => { + scrollDown(); + dispatch('toggleScrollButtons'); +}; + +/** + * Responsible for toggling the disabled state of the scroll buttons + */ +export const toggleScrollButtons = ({ dispatch }) => { + if (canScroll()) { + if (isScrolledToTop()) { + dispatch('disableScrollTop'); + dispatch('enableScrollBottom'); + } else if (isScrolledToBottom()) { + dispatch('disableScrollBottom'); + dispatch('enableScrollTop'); + } else { + dispatch('enableScrollTop'); + dispatch('enableScrollBottom'); + } + } else { + dispatch('disableScrollBottom'); + dispatch('disableScrollTop'); + } +}; + +export const disableScrollBottom = ({ commit }) => commit(types.DISABLE_SCROLL_BOTTOM); +export const disableScrollTop = ({ commit }) => commit(types.DISABLE_SCROLL_TOP); +export const enableScrollBottom = ({ commit }) => commit(types.ENABLE_SCROLL_BOTTOM); +export const enableScrollTop = ({ commit }) => commit(types.ENABLE_SCROLL_TOP); + +/** + * While the automatic scroll down is active, + * we show the scroll down button with an animation + */ +export const toggleScrollAnimation = ({ commit }, toggle) => + commit(types.TOGGLE_SCROLL_ANIMATION, toggle); + +/** + * Responsible to handle automatic scroll + */ +export const toggleScrollisInBottom = ({ commit }, toggle) => { + commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG, toggle); +}; + +export const requestJobLog = ({ commit }) => commit(types.REQUEST_JOB_LOG); + +export const fetchJobLog = ({ dispatch, state }) => + // update trace endpoint once BE compeletes trace re-naming in #340626 + axios + .get(`${state.jobLogEndpoint}/trace.json`, { + params: { state: state.jobLogState }, + }) + .then(({ data }) => { + dispatch('toggleScrollisInBottom', isScrolledToBottom()); + dispatch('receiveJobLogSuccess', data); + + if (data.complete) { + dispatch('stopPollingJobLog'); + } else if (!state.jobLogTimeout) { + dispatch('startPollingJobLog'); + } + }) + .catch((e) => { + if (e.response.status === HTTP_STATUS_FORBIDDEN) { + dispatch('receiveJobLogUnauthorizedError'); + } else { + reportToSentry('job_actions', e); + dispatch('receiveJobLogError'); + } + }); + +export const startPollingJobLog = ({ dispatch, commit }) => { + const jobLogTimeout = setTimeout(() => { + commit(types.SET_JOB_LOG_TIMEOUT, 0); + dispatch('fetchJobLog'); + }, 4000); + + commit(types.SET_JOB_LOG_TIMEOUT, jobLogTimeout); +}; + +export const stopPollingJobLog = ({ state, commit }) => { + clearTimeout(state.jobLogTimeout); + commit(types.SET_JOB_LOG_TIMEOUT, 0); + commit(types.STOP_POLLING_JOB_LOG); +}; + +export const receiveJobLogSuccess = ({ commit }, log) => commit(types.RECEIVE_JOB_LOG_SUCCESS, log); + +export const receiveJobLogError = ({ dispatch }) => { + dispatch('stopPollingJobLog'); + createAlert({ + message: __('An error occurred while fetching the job log.'), + }); +}; + +export const receiveJobLogUnauthorizedError = ({ dispatch }) => { + dispatch('stopPollingJobLog'); + createAlert({ + message: __('The current user is not authorized to access the job log.'), + }); +}; +/** + * When the user clicks a collapsible line in the job + * log, we commit a mutation to update the state + * + * @param {Object} section + */ +export const toggleCollapsibleLine = ({ commit }, section) => + commit(types.TOGGLE_COLLAPSIBLE_LINE, section); + +/** + * Jobs list on sidebar - depend on stages dropdown + */ +export const requestJobsForStage = ({ commit }, stage) => + commit(types.REQUEST_JOBS_FOR_STAGE, stage); + +// On stage click, set selected stage + fetch job +export const fetchJobsForStage = ({ dispatch }, stage = {}) => { + dispatch('requestJobsForStage', stage); + + axios + .get(stage.dropdown_path, { + params: { + retried: 1, + }, + }) + .then(({ data }) => { + const retriedJobs = data.retried.map((job) => ({ ...job, retried: true })); + const jobs = data.latest_statuses.concat(retriedJobs); + + dispatch('receiveJobsForStageSuccess', jobs); + }) + .catch(() => dispatch('receiveJobsForStageError')); +}; +export const receiveJobsForStageSuccess = ({ commit }, data) => + commit(types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, data); + +export const receiveJobsForStageError = ({ commit }) => { + commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR); + createAlert({ + message: __('An error occurred while fetching the jobs.'), + }); +}; + +export const triggerManualJob = ({ state }, variables) => { + const parsedVariables = variables.map((variable) => { + const copyVar = { ...variable }; + delete copyVar.id; + return copyVar; + }); + + axios + .post(state.job.status.action.path, { + job_variables_attributes: parsedVariables, + }) + .catch(() => + createAlert({ + message: __('An error occurred while triggering the job.'), + }), + ); +}; diff --git a/app/assets/javascripts/ci/job_details/store/getters.js b/app/assets/javascripts/ci/job_details/store/getters.js new file mode 100644 index 00000000000..a0f9db7409d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/getters.js @@ -0,0 +1,50 @@ +import { isEmpty } from 'lodash'; +import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; + +export const headerTime = (state) => state.job.started_at || state.job.created_at; + +export const hasForwardDeploymentFailure = (state) => + state?.job?.failure_reason === 'forward_deployment_failure'; + +export const hasUnmetPrerequisitesFailure = (state) => + state?.job?.failure_reason === 'unmet_prerequisites'; + +export const shouldRenderCalloutMessage = (state) => + !isEmpty(state.job.status) && !isEmpty(state.job.callout_message); + +/** + * When the job has not started the value of job.started_at will be null + * When job has started the value of job.started_at will be a string with a date. + */ +export const shouldRenderTriggeredLabel = (state) => Boolean(state.job.started_at); + +export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status); + +/** + * Checks if it the job has a log. + * Used to check if it should render the job log or the empty state + * @returns {Boolean} + */ +export const hasJobLog = (state) => + // update has_trace once BE compeletes trace re-naming in #340626 + state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running'); + +export const emptyStateIllustration = (state) => state?.job?.status?.illustration || {}; + +export const emptyStateAction = (state) => state?.job?.status?.action || null; + +/** + * Shared runners limit is only rendered when + * used quota is bigger or equal than the limit + * + * @returns {Boolean} + */ +export const shouldRenderSharedRunnerLimitWarning = (state) => + !isEmpty(state.job.runners) && + !isEmpty(state.job.runners.quota) && + state.job.runners.quota.used >= state.job.runners.quota.limit; + +export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLogComplete; + +export const hasOfflineRunnersForProject = (state) => + state?.job?.runners?.available && !state?.job?.runners?.online; diff --git a/app/assets/javascripts/ci/job_details/store/index.js b/app/assets/javascripts/ci/job_details/store/index.js new file mode 100644 index 00000000000..b9d76765d8d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + mutations, + getters, + state: state(), + }); diff --git a/app/assets/javascripts/ci/job_details/store/mutation_types.js b/app/assets/javascripts/ci/job_details/store/mutation_types.js new file mode 100644 index 00000000000..4915a826b84 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/mutation_types.js @@ -0,0 +1,31 @@ +export const SET_JOB_ENDPOINT = 'SET_JOB_ENDPOINT'; +export const SET_JOB_LOG_OPTIONS = 'SET_JOB_LOG_OPTIONS'; + +export const HIDE_SIDEBAR = 'HIDE_SIDEBAR'; +export const SHOW_SIDEBAR = 'SHOW_SIDEBAR'; + +export const SCROLL_TO_TOP = 'SCROLL_TO_TOP'; +export const SCROLL_TO_BOTTOM = 'SCROLL_TO_BOTTOM'; +export const DISABLE_SCROLL_BOTTOM = 'DISABLE_SCROLL_BOTTOM'; +export const DISABLE_SCROLL_TOP = 'DISABLE_SCROLL_TOP'; +export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM'; +export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP'; +export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION'; + +export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG = 'TOGGLE_IS_SCROLL_IN_BOTTOM'; + +export const REQUEST_JOB = 'REQUEST_JOB'; +export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS'; +export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR'; + +export const REQUEST_JOB_LOG = 'REQUEST_JOB_LOG'; +export const SET_JOB_LOG_TIMEOUT = 'SET_JOB_LOG_TIMEOUT'; +export const STOP_POLLING_JOB_LOG = 'STOP_POLLING_JOB_LOG'; +export const RECEIVE_JOB_LOG_SUCCESS = 'RECEIVE_JOB_LOG_SUCCESS'; +export const RECEIVE_JOB_LOG_ERROR = 'RECEIVE_JOB_LOG_ERROR'; +export const TOGGLE_COLLAPSIBLE_LINE = 'TOGGLE_COLLAPSIBLE_LINE'; + +export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; +export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE'; +export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS'; +export const RECEIVE_JOBS_FOR_STAGE_ERROR = 'RECEIVE_JOBS_FOR_STAGE_ERROR'; diff --git a/app/assets/javascripts/ci/job_details/store/mutations.js b/app/assets/javascripts/ci/job_details/store/mutations.js new file mode 100644 index 00000000000..b7d7006ee61 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/mutations.js @@ -0,0 +1,134 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; +import { logLinesParser, updateIncrementalJobLog } from './utils'; + +export default { + [types.SET_JOB_ENDPOINT](state, endpoint) { + state.jobEndpoint = endpoint; + }, + + [types.SET_JOB_LOG_OPTIONS](state, options = {}) { + state.jobLogEndpoint = options.pagePath; + state.jobLogState = options.logState; + }, + + [types.HIDE_SIDEBAR](state) { + state.isSidebarOpen = false; + }, + [types.SHOW_SIDEBAR](state) { + state.isSidebarOpen = true; + }, + + [types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) { + if (log.state) { + state.jobLogState = log.state; + } + + if (log.append) { + state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog; + + state.jobLogSize += log.size; + } else { + // When the job still does not have a log + // the job log response will not have a defined + // html or size. We keep the old value otherwise these + // will be set to `null` + state.jobLog = log.lines ? logLinesParser(log.lines, [], window.location.hash) : state.jobLog; + + state.jobLogSize = log.size || state.jobLogSize; + } + + if (state.jobLogSize < log.total) { + state.isJobLogSizeVisible = true; + } else { + state.isJobLogSizeVisible = false; + } + + state.isJobLogComplete = log.complete || state.isJobLogComplete; + }, + + [types.SET_JOB_LOG_TIMEOUT](state, id) { + state.jobLogTimeout = id; + }, + + /** + * Will remove loading animation + */ + [types.STOP_POLLING_JOB_LOG](state) { + state.isJobLogComplete = true; + }, + + /** + * Instead of filtering the array of lines to find the one that must be updated + * we use Vue.set to make this process more performant + * + * https://vuex.vuejs.org/guide/mutations.html#mutations-follow-vue-s-reactivity-rules + * @param {Object} state + * @param {Object} section + */ + [types.TOGGLE_COLLAPSIBLE_LINE](state, section) { + Vue.set(section, 'isClosed', !section.isClosed); + }, + + [types.REQUEST_JOB](state) { + state.isLoading = true; + }, + [types.RECEIVE_JOB_SUCCESS](state, job) { + state.hasError = false; + state.isLoading = false; + state.job = job; + + state.stages = + job.pipeline && job.pipeline.details && job.pipeline.details.stages + ? job.pipeline.details.stages + : []; + + /** + * We only update it on the first request + * The dropdown can be changed by the user + * after the first request, + * and we do not want to hijack that + */ + if (state.selectedStage === '' && job.stage) { + state.selectedStage = job.stage; + } + }, + [types.RECEIVE_JOB_ERROR](state) { + state.isLoading = false; + state.job = {}; + state.hasError = true; + }, + + [types.ENABLE_SCROLL_TOP](state) { + state.isScrollTopDisabled = false; + }, + [types.DISABLE_SCROLL_TOP](state) { + state.isScrollTopDisabled = true; + }, + [types.ENABLE_SCROLL_BOTTOM](state) { + state.isScrollBottomDisabled = false; + }, + [types.DISABLE_SCROLL_BOTTOM](state) { + state.isScrollBottomDisabled = true; + }, + [types.TOGGLE_SCROLL_ANIMATION](state, toggle) { + state.isScrollingDown = toggle; + }, + + [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG](state, toggle) { + state.isScrolledToBottomBeforeReceivingJobLog = toggle; + }, + + [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) { + state.isLoadingJobs = true; + state.selectedStage = stage.name; + }, + [types.RECEIVE_JOBS_FOR_STAGE_SUCCESS](state, jobs) { + state.isLoadingJobs = false; + state.jobs = jobs; + }, + [types.RECEIVE_JOBS_FOR_STAGE_ERROR](state) { + state.isLoadingJobs = false; + state.jobs = []; + }, +}; diff --git a/app/assets/javascripts/ci/job_details/store/state.js b/app/assets/javascripts/ci/job_details/store/state.js new file mode 100644 index 00000000000..dfff65c364d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/state.js @@ -0,0 +1,33 @@ +export default () => ({ + jobEndpoint: null, + jobLogEndpoint: null, + + // sidebar + isSidebarOpen: true, + + isLoading: false, + hasError: false, + job: {}, + + // scroll buttons state + isScrollBottomDisabled: true, + isScrollTopDisabled: true, + + // Used to check if we should keep the automatic scroll + isScrolledToBottomBeforeReceivingJobLog: true, + + jobLog: [], + isJobLogComplete: false, + jobLogSize: 0, + isJobLogSizeVisible: false, + jobLogTimeout: 0, + + // used as a query parameter to fetch the job log + jobLogState: null, + + // sidebar dropdown & list of jobs + isLoadingJobs: false, + selectedStage: '', + stages: [], + jobs: [], +}); diff --git a/app/assets/javascripts/ci/job_details/store/utils.js b/app/assets/javascripts/ci/job_details/store/utils.js new file mode 100644 index 00000000000..bc76901026d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/utils.js @@ -0,0 +1,195 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + +/** + * Adds the line number property + * @param Object line + * @param Number lineNumber + */ +export const parseLine = (line = {}, lineNumber) => ({ + ...line, + lineNumber, +}); + +/** + * When a line has `section_header` set to true, we create a new + * structure to allow to nest the lines that belong to the + * collapsible section + * + * @param Object line + * @param Number lineNumber + */ +export const parseHeaderLine = (line = {}, lineNumber, hash) => { + // if a hash is present in the URL then we ensure + // all sections are visible so we can scroll to the hash + // in the DOM + if (hash) { + return { + isClosed: false, + isHeader: true, + line: parseLine(line, lineNumber), + lines: [], + }; + } + + return { + isClosed: parseBoolean(line.section_options?.collapsed), + isHeader: true, + line: parseLine(line, lineNumber), + lines: [], + }; +}; + +/** + * Finds the matching header section + * for the section_duration object and adds it to it + * + * { + * isHeader: true, + * line: { + * content: [], + * lineNumber: 0, + * section_duration: "", + * }, + * lines: [] + * } + * + * @param Array data + * @param Object durationLine + */ +export function addDurationToHeader(data, durationLine) { + data.forEach((el) => { + if (el.line && el.line.section === durationLine.section) { + el.line.section_duration = durationLine.section_duration; + } + }); +} + +/** + * Check is the current section belongs to a collapsible section + * + * @param Array acc + * @param Object last + * @param Object section + * + * @returns Boolean + */ +export const isCollapsibleSection = (acc = [], last = {}, section = {}) => + acc.length > 0 && + last.isHeader === true && + !section.section_duration && + section.section === last.line.section; + +/** + * Returns the lineNumber of the last line in + * a parsed log + * + * @param Array acc + * @returns Number + */ +export const getIncrementalLineNumber = (acc) => { + let lineNumberValue; + const lastIndex = acc.length - 1; + const lastElement = acc[lastIndex]; + const nestedLines = lastElement.lines; + + if (lastElement.isHeader && !nestedLines.length && lastElement.line) { + lineNumberValue = lastElement.line.lineNumber; + } else if (lastElement.isHeader && nestedLines.length) { + lineNumberValue = nestedLines[nestedLines.length - 1].lineNumber; + } else { + lineNumberValue = lastElement.lineNumber; + } + + return lineNumberValue === 0 ? 1 : lineNumberValue + 1; +}; + +/** + * Parses the job log content into a structure usable by the template + * + * For collaspible lines (section_header = true): + * - creates a new array to hold the lines that are collapsible, + * - adds a isClosed property to handle toggle + * - adds a isHeader property to handle template logic + * - adds the section_duration + * For each line: + * - adds the index as lineNumber + * + * @param Array lines + * @param Array accumulator + * @returns Array parsed log lines + */ +export const logLinesParser = (lines = [], accumulator = [], hash = '') => + lines.reduce( + (acc, line, index) => { + const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index; + + const last = acc[acc.length - 1]; + + // If the object is an header, we parse it into another structure + if (line.section_header) { + acc.push(parseHeaderLine(line, lineNumber, hash)); + } else if (isCollapsibleSection(acc, last, line)) { + // if the object belongs to a nested section, we append it to the new `lines` array of the + // previously formatted header + last.lines.push(parseLine(line, lineNumber)); + } else if (line.section_duration) { + // if the line has section_duration, we look for the correct header to add it + addDurationToHeader(acc, line); + } else { + // otherwise it's a regular line + acc.push(parseLine(line, lineNumber)); + } + + return acc; + }, + [...accumulator], + ); + +/** + * Finds the repeated offset, removes the old one + * + * Returns a new array with the updated log without + * the repeated offset + * + * @param Array newLog + * @param Array oldParsed + * @returns Array + * + */ +export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { + const cloneOldLog = [...oldParsed]; + const lastIndex = cloneOldLog.length - 1; + const last = cloneOldLog[lastIndex]; + + const firstNew = newLog[0]; + + if (last && firstNew) { + if (last.offset === firstNew.offset || (last.line && last.line.offset === firstNew.offset)) { + cloneOldLog.splice(lastIndex); + } else if (last.lines && last.lines.length) { + const lastNestedIndex = last.lines.length - 1; + const lastNested = last.lines[lastNestedIndex]; + if (lastNested.offset === firstNew.offset) { + last.lines.splice(lastNestedIndex); + } + } + } + + return cloneOldLog; +}; + +/** + * When the job log is not complete, backend may send the last received line + * in the new response. + * + * We need to check if that is the case by looking for the offset property + * before parsing the incremental part + * + * @param array oldLog + * @param array newLog + */ +export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => { + const parsedLog = findOffsetAndRemove(newLog, oldParsed); + + return logLinesParser(newLog, parsedLog); +}; diff --git a/app/assets/javascripts/ci/job_details/utils.js b/app/assets/javascripts/ci/job_details/utils.js new file mode 100644 index 00000000000..4d06c241b4f --- /dev/null +++ b/app/assets/javascripts/ci/job_details/utils.js @@ -0,0 +1,29 @@ +export const compactJobLog = (jobLog) => { + const compactedLog = []; + + jobLog.forEach((obj) => { + // push header section line + if (obj.line && obj.isHeader) { + compactedLog.push(obj.line); + } + + // push lines within section header + if (obj.lines?.length > 0) { + compactedLog.push(...obj.lines); + } + + // push lines from plain log + if (!obj.lines && obj.content.length > 0) { + compactedLog.push(obj); + } + }); + + return compactedLog; +}; + +export const filterAnnotations = (annotations, type) => { + return [...annotations] + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((annotationList) => annotationList.data) + .flatMap((annotation) => annotation[type] ?? []); +}; |