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/jobs/components/job/sidebar')
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue91
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue47
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue77
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue66
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue53
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue35
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue99
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue149
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue45
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue102
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue112
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue167
-rw-r--r--app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue98
13 files changed, 1141 insertions, 0 deletions
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
new file mode 100644
index 00000000000..2018942a7e8
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlButton, GlButtonGroup, GlIcon, GlLink } from '@gitlab/ui';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+
+export default {
+ components: {
+ GlButton,
+ GlButtonGroup,
+ GlIcon,
+ GlLink,
+ 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">{{ s__('Job|Job artifacts') }}</div>
+ <p
+ v-if="isExpired || willExpire"
+ class="build-detail-row"
+ data-testid="artifacts-remove-timeline"
+ >
+ <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span>
+ <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</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" />
+ </gl-link>
+ </p>
+ <p v-else-if="isLocked" class="build-detail-row">
+ <span data-testid="job-locked-message">{{
+ s__(
+ 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.',
+ )
+ }}</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"
+ >{{ s__('Job|Keep') }}</gl-button
+ >
+ <gl-button
+ v-if="artifact.download_path"
+ :href="artifact.download_path"
+ rel="nofollow"
+ data-testid="download-artifacts"
+ download
+ >{{ s__('Job|Download') }}</gl-button
+ >
+ <gl-button
+ v-if="artifact.browse_path"
+ :href="artifact.browse_path"
+ data-testid="browse-artifacts"
+ data-qa-selector="browse_artifacts_button"
+ >{{ s__('Job|Browse') }}</gl-button
+ >
+ </gl-button-group>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue
new file mode 100644
index 00000000000..7f25ca8a94d
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue
@@ -0,0 +1,47 @@
+<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>
+ <span class="gl-font-weight-bold">{{ __('Commit') }}</span>
+
+ <gl-link :href="commit.commit_path" class="gl-text-blue-600!" data-testid="commit-sha">
+ {{ commit.short_id }}
+ </gl-link>
+
+ <clipboard-button
+ :text="commit.id"
+ :title="__('Copy commit SHA')"
+ category="tertiary"
+ size="small"
+ />
+
+ <span v-if="mergeRequest">
+ {{ __('in') }}
+ <gl-link :href="mergeRequest.path" class="gl-text-blue-600!" data-testid="link-commit"
+ >!{{ mergeRequest.iid }}</gl-link
+ >
+ </span>
+
+ <p class="gl-mb-0">{{ commit.title }}</p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue
new file mode 100644
index 00000000000..097ab3b4cf6
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue
@@ -0,0 +1,77 @@
+<script>
+import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import delayedJobMixin from '~/jobs/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: 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"
+ :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-2" :size="14" />
+
+ <span class="gl-text-truncate gl-w-full">{{ jobName }}</span>
+
+ <gl-icon v-if="job.retried" name="retry" />
+ </gl-link>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
new file mode 100644
index 00000000000..913924cc7b1
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue
@@ -0,0 +1,66 @@
+<script>
+import { GlLink, GlModal } from '@gitlab/ui';
+import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants';
+
+export default {
+ name: 'JobRetryForwardDeploymentModal',
+ components: {
+ GlLink,
+ GlModal,
+ },
+ i18n: {
+ ...JOB_RETRY_FORWARD_DEPLOYMENT_MODAL,
+ },
+ 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/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
new file mode 100644
index 00000000000..dd620977f0c
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue
@@ -0,0 +1,53 @@
+<script>
+import { GlButton, GlModalDirective } from '@gitlab/ui';
+import { mapGetters } from 'vuex';
+import { JOB_SIDEBAR_COPY } from '~/jobs/constants';
+
+export default {
+ name: 'JobSidebarRetryButton',
+ i18n: {
+ retryLabel: JOB_SIDEBAR_COPY.retry,
+ },
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlModal: GlModalDirective,
+ },
+ props: {
+ modalId: {
+ type: String,
+ required: true,
+ },
+ href: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapGetters(['hasForwardDeploymentFailure']),
+ },
+};
+</script>
+<template>
+ <gl-button
+ v-if="hasForwardDeploymentFailure"
+ v-gl-modal="modalId"
+ :aria-label="$options.i18n.retryLabel"
+ category="primary"
+ variant="confirm"
+ icon="retry"
+ data-testid="retry-job-button"
+ />
+
+ <gl-button
+ v-else
+ :href="href"
+ :aria-label="$options.i18n.retryLabel"
+ category="primary"
+ variant="confirm"
+ icon="retry"
+ data-method="post"
+ data-testid="retry-job-link"
+ />
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue
new file mode 100644
index 00000000000..df64b6422c7
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue
@@ -0,0 +1,35 @@
+<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="builds-container">
+ <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/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
new file mode 100644
index 00000000000..263b2d141c9
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue
@@ -0,0 +1,99 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
+import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
+
+export default {
+ name: 'LegacySidebarHeader',
+ i18n: {
+ ...JOB_SIDEBAR_COPY,
+ },
+ forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ JobSidebarRetryButton,
+ TooltipOnTruncate,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleSidebar']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="job.name" truncate-target="child"
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
+ {{ job.name }}
+ </h4>
+ </tooltip-on-truncate>
+ <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
+ <gl-button
+ v-if="erasePath"
+ v-gl-tooltip.left
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
+ :href="erasePath"
+ :data-confirm="$options.i18n.eraseLogConfirmText"
+ class="gl-mr-2"
+ data-testid="job-log-erase-link"
+ data-confirm-btn-variant="danger"
+ data-method="post"
+ icon="remove"
+ />
+ <job-sidebar-retry-button
+ v-if="job.retry_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.retryJobButtonLabel"
+ :aria-label="$options.i18n.retryJobButtonLabel"
+ :category="retryButtonCategory"
+ :href="job.retry_path"
+ :modal-id="$options.forwardDeploymentFailureModalId"
+ variant="confirm"
+ data-qa-selector="retry_button"
+ data-testid="retry-button"
+ />
+ <gl-button
+ v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
+ :href="job.cancel_path"
+ variant="danger"
+ icon="cancel"
+ data-method="post"
+ data-testid="cancel-button"
+ rel="nofollow"
+ />
+ <gl-button
+ :aria-label="$options.i18n.toggleSidebar"
+ category="tertiary"
+ class="gl-md-display-none gl-ml-2"
+ icon="chevron-double-lg-right"
+ @click="toggleSidebar"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
new file mode 100644
index 00000000000..b0db48df01f
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue
@@ -0,0 +1,149 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import { mapActions, mapGetters, mapState } from 'vuex';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
+import CommitBlock from './commit_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 ArtifactsBlock from './artifacts_block.vue';
+import LegacySidebarHeader from './legacy_sidebar_header.vue';
+import SidebarHeader from './sidebar_header.vue';
+import StagesDropdown from './stages_dropdown.vue';
+import TriggerBlock from './trigger_block.vue';
+
+export default {
+ name: 'JobSidebar',
+ i18n: {
+ ...JOB_SIDEBAR_COPY,
+ },
+ borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'],
+ forwardDeploymentFailureModalId,
+ components: {
+ ArtifactsBlock,
+ CommitBlock,
+ GlButton,
+ GlIcon,
+ JobsContainer,
+ JobRetryForwardDeploymentModal,
+ JobSidebarDetailsContainer,
+ LegacySidebarHeader,
+ SidebarHeader,
+ StagesDropdown,
+ TriggerBlock,
+ },
+ mixins: [glFeatureFlagsMixin()],
+ props: {
+ artifactHelpUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ 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;
+ },
+ hasTriggers() {
+ return !isEmpty(this.job.trigger);
+ },
+ isGraphQL() {
+ return this.glFeatures?.graphqlJobApp;
+ },
+ commit() {
+ return this.job?.pipeline?.commit || {};
+ },
+ shouldShowJobRetryForwardDeploymentModal() {
+ return this.job.retry_path && this.hasForwardDeploymentFailure;
+ },
+ },
+ 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">
+ <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" />
+ <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" />
+ <div
+ v-if="job.terminal_path || job.new_issue_path"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ >
+ <gl-button
+ v-if="job.new_issue_path"
+ :href="job.new_issue_path"
+ category="secondary"
+ variant="confirm"
+ data-testid="job-new-issue"
+ >
+ {{ $options.i18n.newIssue }}
+ </gl-button>
+ <gl-button
+ v-if="job.terminal_path"
+ :href="job.terminal_path"
+ target="_blank"
+ data-testid="terminal-link"
+ >
+ {{ $options.i18n.debug }}
+ <gl-icon name="external-link" />
+ </gl-button>
+ </div>
+
+ <job-sidebar-details-container class="gl-py-5" :class="$options.borderTopClass" />
+
+ <artifacts-block
+ v-if="hasArtifact"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :artifact="job.artifact"
+ :help-url="artifactHelpUrl"
+ />
+
+ <trigger-block
+ v-if="hasTriggers"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :trigger="job.trigger"
+ />
+
+ <commit-block
+ :commit="commit"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :merge-request="job.merge_request"
+ />
+
+ <stages-dropdown
+ v-if="job.pipeline"
+ class="gl-py-5"
+ :class="$options.borderTopClass"
+ :pipeline="job.pipeline"
+ :selected-stage="selectedStage"
+ :stages="stages"
+ @requestSidebarStageDropdown="fetchJobsForStage"
+ />
+ </div>
+
+ <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" />
+ </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/jobs/components/job/sidebar/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
new file mode 100644
index 00000000000..05567328660
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue
@@ -0,0 +1,45 @@
+<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: '',
+ },
+ },
+ computed: {
+ hasTitle() {
+ return this.title.length > 0;
+ },
+ hasHelpURL() {
+ return this.helpUrl.length > 0;
+ },
+ },
+};
+</script>
+<template>
+ <p class="gl-display-flex gl-justify-content-space-between gl-mb-2">
+ <span v-if="hasTitle"
+ ><b>{{ title }}:</b> {{ value }}</span
+ >
+ <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank">
+ <gl-icon name="question-o" />
+ </gl-link>
+ </p>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
new file mode 100644
index 00000000000..523710598bf
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue
@@ -0,0 +1,102 @@
+<script>
+import { GlButton, GlTooltipDirective } from '@gitlab/ui';
+import { mapActions } from 'vuex';
+import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue';
+import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants';
+import JobSidebarRetryButton from './job_sidebar_retry_button.vue';
+
+// This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue
+// It is meant to fetch the job information via GraphQL instead of REST API.
+
+export default {
+ name: 'SidebarHeader',
+ i18n: {
+ ...JOB_SIDEBAR_COPY,
+ },
+ forwardDeploymentFailureModalId,
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ components: {
+ GlButton,
+ JobSidebarRetryButton,
+ TooltipOnTruncate,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ default: () => ({}),
+ },
+ erasePath: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ },
+ computed: {
+ retryButtonCategory() {
+ return this.job.status && this.job.recoverable ? 'primary' : 'secondary';
+ },
+ },
+ methods: {
+ ...mapActions(['toggleSidebar']),
+ },
+};
+</script>
+
+<template>
+ <div class="gl-py-5 gl-display-flex gl-align-items-center">
+ <tooltip-on-truncate :title="job.name" truncate-target="child"
+ ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate">
+ {{ job.name }}
+ </h4>
+ </tooltip-on-truncate>
+ <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right">
+ <gl-button
+ v-if="erasePath"
+ v-gl-tooltip.left
+ :title="$options.i18n.eraseLogButtonLabel"
+ :aria-label="$options.i18n.eraseLogButtonLabel"
+ :href="erasePath"
+ :data-confirm="$options.i18n.eraseLogConfirmText"
+ class="gl-mr-2"
+ data-testid="job-log-erase-link"
+ data-confirm-btn-variant="danger"
+ data-method="post"
+ icon="remove"
+ />
+ <job-sidebar-retry-button
+ v-if="job.retry_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.retryJobButtonLabel"
+ :aria-label="$options.i18n.retryJobButtonLabel"
+ :category="retryButtonCategory"
+ :href="job.retry_path"
+ :modal-id="$options.forwardDeploymentFailureModalId"
+ variant="confirm"
+ data-qa-selector="retry_button"
+ data-testid="retry-button"
+ />
+ <gl-button
+ v-if="job.cancel_path"
+ v-gl-tooltip.left
+ :title="$options.i18n.cancelJobButtonLabel"
+ :aria-label="$options.i18n.cancelJobButtonLabel"
+ :href="job.cancel_path"
+ variant="danger"
+ icon="cancel"
+ data-method="post"
+ data-testid="cancel-button"
+ rel="nofollow"
+ />
+ <gl-button
+ :aria-label="$options.i18n.toggleSidebar"
+ category="tertiary"
+ class="gl-md-display-none gl-ml-2"
+ icon="chevron-double-lg-right"
+ @click="toggleSidebar"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
new file mode 100644
index 00000000000..3b1509e5be5
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue
@@ -0,0 +1,112 @@
+<script>
+import { mapState } from 'vuex';
+import { GlBadge } from '@gitlab/ui';
+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.runner ||
+ this.job.coverage,
+ );
+ },
+ 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,
+ });
+ },
+ },
+ i18n: {
+ COVERAGE: __('Coverage'),
+ FINISHED: __('Finished'),
+ ERASED: __('Erased'),
+ QUEUED: __('Queued'),
+ RUNNER: __('Runner'),
+ TAGS: __('Tags:'),
+ TIMEOUT: __('Timeout'),
+ },
+ RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html',
+};
+</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.RUNNER_HELP_URL"
+ :value="timeout"
+ data-testid="job-timeout"
+ :title="$options.i18n.TIMEOUT"
+ />
+ <detail-row v-if="job.runner" :value="runnerId" :title="$options.i18n.RUNNER" />
+ <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">{{ tag }}</gl-badge>
+ </p>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
new file mode 100644
index 00000000000..e3afe9b7c67
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue
@@ -0,0 +1,167 @@
+<script>
+import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
+import Mousetrap from 'mousetrap';
+import { s__ } from '~/locale';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+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: {
+ CiIcon,
+ ClipboardButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ pipeline: {
+ type: Object,
+ required: true,
+ },
+ stages: {
+ type: Array,
+ required: true,
+ },
+ selectedStage: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ 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}');
+ } else if (!this.isTriggeredByMergeRequest) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}');
+ } else if (!this.isMergeRequestPipeline) {
+ return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} 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;
+ } else 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="js-pipeline-info" data-testid="pipeline-info">
+ <ci-icon :status="pipeline.details.status" />
+ <gl-sprintf :message="pipelineInfo">
+ <template #bold="{ content }">
+ <span class="font-weight-bold">{{ content }}</span>
+ </template>
+ <template #id>
+ <gl-link
+ :href="pipeline.path"
+ class="js-pipeline-path link-commit"
+ data-testid="pipeline-path"
+ data-qa-selector="pipeline_path"
+ >#{{ pipeline.id }}</gl-link
+ >
+ </template>
+ <template #mrId>
+ <gl-link
+ :href="pipeline.merge_request.path"
+ class="link-commit ref-name"
+ data-testid="mr-link"
+ >!{{ pipeline.merge_request.iid }}</gl-link
+ >
+ </template>
+ <template #ref>
+ <gl-link
+ :href="pipeline.ref.path"
+ class="link-commit ref-name"
+ 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"
+ 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"
+ 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-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3">
+ <gl-dropdown-item
+ v-for="stage in stages"
+ :key="stage.name"
+ class="js-stage-item stage-item"
+ @click="onStageClick(stage)"
+ >
+ {{ stage.name }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue
new file mode 100644
index 00000000000..1afc1c9a595
--- /dev/null
+++ b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue
@@ -0,0 +1,98 @@
+<script>
+import { GlButton, GlTableLite } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!';
+const DEFAULT_TH_CLASSES =
+ 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1!';
+
+export default {
+ fields: [
+ {
+ key: 'key',
+ label: __('Key'),
+ tdAttr: { 'data-testid': 'trigger-build-key' },
+ tdClass: DEFAULT_TD_CLASSES,
+ thClass: DEFAULT_TH_CLASSES,
+ },
+ {
+ key: 'value',
+ label: __('Value'),
+ tdAttr: { 'data-testid': 'trigger-build-value' },
+ tdClass: DEFAULT_TD_CLASSES,
+ thClass: DEFAULT_TH_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-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>