Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/ci/job_details')
-rw-r--r--app/assets/javascripts/ci/job_details/components/empty_state.vue100
-rw-r--r--app/assets/javascripts/ci/job_details/components/environments_block.vue214
-rw-r--r--app/assets/javascripts/ci/job_details/components/erased_block.vue49
-rw-r--r--app/assets/javascripts/ci/job_details/components/job_header.vue148
-rw-r--r--app/assets/javascripts/ci/job_details/components/job_log_controllers.vue260
-rw-r--r--app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue71
-rw-r--r--app/assets/javascripts/ci/job_details/components/log/duration_badge.vue20
-rw-r--r--app/assets/javascripts/ci/job_details/components/log/line.vue83
-rw-r--r--app/assets/javascripts/ci/job_details/components/log/line_header.vue81
-rw-r--r--app/assets/javascripts/ci/job_details/components/log/line_number.vue34
-rw-r--r--app/assets/javascripts/ci/job_details/components/log/log.vue106
-rw-r--r--app/assets/javascripts/ci/job_details/components/log/utils.js12
-rw-r--r--app/assets/javascripts/ci/job_details/components/manual_variables_form.vue305
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue120
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue54
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue34
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue77
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue72
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue84
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue36
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue127
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue64
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue168
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue130
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue179
-rw-r--r--app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue94
-rw-r--r--app/assets/javascripts/ci/job_details/components/stuck_block.vue91
-rw-r--r--app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue33
-rw-r--r--app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql11
-rw-r--r--app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql6
-rw-r--r--app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql11
-rw-r--r--app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql12
-rw-r--r--app/assets/javascripts/ci/job_details/index.js69
-rw-r--r--app/assets/javascripts/ci/job_details/job_app.vue349
-rw-r--r--app/assets/javascripts/ci/job_details/store/actions.js277
-rw-r--r--app/assets/javascripts/ci/job_details/store/getters.js50
-rw-r--r--app/assets/javascripts/ci/job_details/store/index.js17
-rw-r--r--app/assets/javascripts/ci/job_details/store/mutation_types.js31
-rw-r--r--app/assets/javascripts/ci/job_details/store/mutations.js134
-rw-r--r--app/assets/javascripts/ci/job_details/store/state.js33
-rw-r--r--app/assets/javascripts/ci/job_details/store/utils.js195
-rw-r--r--app/assets/javascripts/ci/job_details/utils.js29
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] ?? []);
+};