diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-12 03:10:03 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-12 03:10:03 +0300 |
commit | 65a27e88f0d422cd19bf3c250762a9b100d56ef1 (patch) | |
tree | 5f5e8f73bde40e45443ed72bb58ac55b2a093527 /app/assets/javascripts/ci | |
parent | ab82d93f5ba566b67dd1300da1cafe3ecdd13122 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/ci')
73 files changed, 5623 insertions, 18 deletions
diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index 9786f25ed87..3d5ed327dc7 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -2,7 +2,8 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { mapEnvironmentNames, reportMessageToSentry } from '../utils'; +import { reportMessageToSentry } from '~/ci/utils'; +import { mapEnvironmentNames } from '../utils'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, diff --git a/app/assets/javascripts/ci/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_variable_list/utils.js index eeca69274ce..1faa97a5f73 100644 --- a/app/assets/javascripts/ci/ci_variable_list/utils.js +++ b/app/assets/javascripts/ci/ci_variable_list/utils.js @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/browser'; import { uniq } from 'lodash'; import { allEnvironments } from './constants'; @@ -49,12 +48,3 @@ export const convertEnvironmentScope = (environmentScope = '') => { export const mapEnvironmentNames = (nodes = []) => { return nodes.map((env) => env.name); }; - -export const reportMessageToSentry = (component, message, context) => { - Sentry.withScope((scope) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - scope.setContext('Vue data', context); - scope.setTag('component', component); - Sentry.captureMessage(message); - }); -}; diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue new file mode 100644 index 00000000000..86ccdb2c87b --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue @@ -0,0 +1,99 @@ +<script> +import { GlFilteredSearch } from '@gitlab/ui'; +import { + OPERATOR_IS, + OPERATORS_IS, + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, + TOKEN_TITLE_JOBS_RUNNER_TYPE, + TOKEN_TYPE_JOBS_RUNNER_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import JobStatusToken from './tokens/job_status_token.vue'; +import JobRunnerTypeToken from './tokens/job_runner_type_token.vue'; + +export default { + components: { + GlFilteredSearch, + }, + mixins: [glFeatureFlagsMixin()], + props: { + queryString: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + tokens() { + const tokens = [ + { + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + token: JobStatusToken, + operators: OPERATORS_IS, + }, + ]; + + if (this.glFeatures.adminJobsFilterRunnerType) { + tokens.push({ + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + title: TOKEN_TITLE_JOBS_RUNNER_TYPE, + unique: true, + token: JobRunnerTypeToken, + operators: OPERATORS_IS, + }); + } + + return tokens; + }, + filteredSearchValue() { + return Object.entries(this.queryString || {}).reduce( + (acc, [queryStringKey, queryStringValue]) => { + switch (queryStringKey) { + case 'statuses': + return [ + ...acc, + { + type: TOKEN_TYPE_STATUS, + value: { data: queryStringValue, operator: OPERATOR_IS }, + }, + ]; + case 'runnerTypes': + if (!this.glFeatures.adminJobsFilterRunnerType) { + return acc; + } + + return [ + ...acc, + { + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + value: { data: queryStringValue, operator: OPERATOR_IS }, + }, + ]; + default: + return acc; + } + }, + [], + ); + }, + }, + methods: { + onSubmit(filters) { + this.$emit('filterJobsBySearch', filters); + }, + }, +}; +</script> + +<template> + <gl-filtered-search + :placeholder="s__('Jobs|Filter jobs')" + :available-tokens="tokens" + :value="filteredSearchValue" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js b/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js new file mode 100644 index 00000000000..86b8290864c --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js @@ -0,0 +1,23 @@ +export const jobStatusValues = [ + 'CANCELED', + 'CREATED', + 'FAILED', + 'MANUAL', + 'SUCCESS', + 'PENDING', + 'PREPARING', + 'RUNNING', + 'SCHEDULED', + 'SKIPPED', + 'WAITING_FOR_RESOURCE', +]; + +export const JOB_RUNNER_TYPE_INSTANCE_TYPE = 'INSTANCE_TYPE'; +export const JOB_RUNNER_TYPE_GROUP_TYPE = 'GROUP_TYPE'; +export const JOB_RUNNER_TYPE_PROJECT_TYPE = 'PROJECT_TYPE'; + +export const jobRunnerTypeValues = [ + JOB_RUNNER_TYPE_INSTANCE_TYPE, + JOB_RUNNER_TYPE_GROUP_TYPE, + JOB_RUNNER_TYPE_PROJECT_TYPE, +]; diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue new file mode 100644 index 00000000000..5bd3693b4d9 --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue @@ -0,0 +1,79 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + JOB_RUNNER_TYPE_INSTANCE_TYPE, + JOB_RUNNER_TYPE_GROUP_TYPE, + JOB_RUNNER_TYPE_PROJECT_TYPE, +} from '../constants'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + runnerTypes() { + return [ + { + class: 'ci-runner-runner-type-instance', + icon: 'users', + text: s__('Runners|Instance'), + value: JOB_RUNNER_TYPE_INSTANCE_TYPE, + }, + { + class: 'ci-runner-runner-type-group', + icon: 'group', + text: s__('Runners|Group'), + value: JOB_RUNNER_TYPE_GROUP_TYPE, + }, + { + class: 'ci-runner-runner-type-project', + icon: 'project', + text: s__('Runners|Project'), + value: JOB_RUNNER_TYPE_PROJECT_TYPE, + }, + ]; + }, + findActiveRunnerType() { + return this.runnerTypes.find((runnerType) => runnerType.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveRunnerType.class"> + <gl-icon :name="findActiveRunnerType.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveRunnerType.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(runnerType, index) in runnerTypes" + :key="index" + :value="runnerType.value" + > + <div class="gl-display-flex" :class="runnerType.class"> + <gl-icon :name="runnerType.icon" class="gl-mr-3" /> + <span>{{ runnerType.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue new file mode 100644 index 00000000000..aad86ded80a --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue @@ -0,0 +1,122 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + statuses() { + return [ + { + class: 'ci-status-icon-canceled', + icon: 'status_canceled', + text: s__('Job|Canceled'), + value: 'CANCELED', + }, + { + class: 'ci-status-icon-created', + icon: 'status_created', + text: s__('Job|Created'), + value: 'CREATED', + }, + { + class: 'ci-status-icon-failed', + icon: 'status_failed', + text: s__('Job|Failed'), + value: 'FAILED', + }, + { + class: 'ci-status-icon-manual', + icon: 'status_manual', + text: s__('Job|Manual'), + value: 'MANUAL', + }, + { + class: 'ci-status-icon-success', + icon: 'status_success', + text: s__('Job|Passed'), + value: 'SUCCESS', + }, + { + class: 'ci-status-icon-pending', + icon: 'status_pending', + text: s__('Job|Pending'), + value: 'PENDING', + }, + { + class: 'ci-status-icon-preparing', + icon: 'status_preparing', + text: s__('Job|Preparing'), + value: 'PREPARING', + }, + { + class: 'ci-status-icon-running', + icon: 'status_running', + text: s__('Job|Running'), + value: 'RUNNING', + }, + { + class: 'ci-status-icon-scheduled', + icon: 'status_scheduled', + text: s__('Job|Scheduled'), + value: 'SCHEDULED', + }, + { + class: 'ci-status-icon-skipped', + icon: 'status_skipped', + text: s__('Job|Skipped'), + value: 'SKIPPED', + }, + { + class: 'ci-status-icon-waiting-for-resource', + icon: 'status-waiting', + text: s__('Job|Waiting for resource'), + value: 'WAITING_FOR_RESOURCE', + }, + ]; + }, + findActiveStatus() { + return this.statuses.find((status) => status.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveStatus.class"> + <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveStatus.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(status, index) in statuses" + :key="index" + :value="status.value" + > + <div class="gl-display-flex" :class="status.class"> + <gl-icon :name="status.icon" class="gl-mr-3" /> + <span>{{ status.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js b/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js new file mode 100644 index 00000000000..43c0da72d3d --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js @@ -0,0 +1,22 @@ +import { jobStatusValues, jobRunnerTypeValues } from './constants'; + +// validates query string used for filtered search +// on jobs table to ensure GraphQL query is called correctly +export const validateQueryString = (queryStringObj) => { + return Object.entries(queryStringObj).reduce((acc, [queryStringKey, queryStringValue]) => { + switch (queryStringKey) { + case 'statuses': { + const statusValue = queryStringValue.toUpperCase(); + const statusValueValid = jobStatusValues.includes(statusValue); + return statusValueValid ? { ...acc, statuses: statusValue } : acc; + } + case 'runnerTypes': { + const runnerTypesValue = queryStringValue.toUpperCase(); + const runnerTypesValueValid = jobRunnerTypeValues.includes(runnerTypesValue); + return runnerTypesValueValid ? { ...acc, runnerTypes: runnerTypesValue } : acc; + } + default: + return acc; + } + }, null); +}; diff --git a/app/assets/javascripts/ci/constants.js b/app/assets/javascripts/ci/constants.js new file mode 100644 index 00000000000..7cc41a8731a --- /dev/null +++ b/app/assets/javascripts/ci/constants.js @@ -0,0 +1,12 @@ +import { __ } from '~/locale'; + +export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; + +export const JOB_GRAPHQL_ERRORS = { + jobMutationErrorText: __('There was an error running the job. Please try again.'), + jobQueryErrorText: __('There was an error fetching the job.'), +}; + +export const SUCCESS_STATUS = 'SUCCESS'; +export const PASSED_STATUS = 'passed'; +export const MANUAL_STATUS = 'manual'; diff --git a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue index 27ee1b794f6..f02d59af1d9 100644 --- a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue +++ b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue @@ -3,7 +3,7 @@ import { produce } from 'immer'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { reportMessageToSentry } from '~/ci/ci_variable_list/utils'; +import { reportMessageToSentry } from '~/ci/utils'; import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; import getInheritedCiVariables from '../graphql/queries/inherited_ci_variables.query.graphql'; 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_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..7f25ca8a94d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/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/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..572544db526 --- /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: 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/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..df64b6422c7 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/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/ci/job_details/components/sidebar/sidebar.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue new file mode 100644 index 00000000000..4711d5b00e3 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue @@ -0,0 +1,124 @@ +<script> +import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapGetters, mapState } from 'vuex'; +import { forwardDeploymentFailureModalId } from '~/ci/constants'; +import ArtifactsBlock from './artifacts_block.vue'; +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 SidebarHeader from './sidebar_header.vue'; +import StagesDropdown from './stages_dropdown.vue'; +import TriggerBlock from './trigger_block.vue'; + +export default { + name: 'JobSidebar', + borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], + forwardDeploymentFailureModalId, + components: { + ArtifactsBlock, + CommitBlock, + JobsContainer, + JobRetryForwardDeploymentModal, + JobSidebarDetailsContainer, + SidebarHeader, + StagesDropdown, + TriggerBlock, + }, + 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; + }, + 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; + }, + }, + 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"> + <sidebar-header + :rest-job="job" + :job-id="job.id" + @updateVariables="$emit('updateVariables')" + /> + + <job-sidebar-details-container class="gl-py-4" :class="$options.borderTopClass" /> + + <artifacts-block + v-if="hasArtifact" + class="gl-py-4" + :class="$options.borderTopClass" + :artifact="job.artifact" + :help-url="artifactHelpUrl" + /> + + <trigger-block + v-if="hasTriggers" + class="gl-py-4" + :class="$options.borderTopClass" + :trigger="job.trigger" + /> + + <commit-block + :commit="commit" + class="gl-py-4" + :class="$options.borderTopClass" + :merge-request="job.merge_request" + /> + + <stages-dropdown + v-if="job.pipeline" + class="gl-py-4" + :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/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..0ba34eafa58 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue @@ -0,0 +1,59 @@ +<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="gl-display-flex gl-justify-content-space-between gl-mb-2"> + <span v-if="hasTitle"> + <b>{{ 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 }}</span> + </span> + <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank" data-testid="job-sidebar-help-link"> + <gl-icon name="question-o" /> + </gl-link> + </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..d4de53f9807 --- /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 class="gl-py-4"> + <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="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/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..09335476008 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue @@ -0,0 +1,124 @@ +<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.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, + }); + }, + runnerAdminPath() { + return this.job?.runner?.admin_path || ''; + }, + }, + i18n: { + COVERAGE: __('Coverage'), + FINISHED: __('Finished'), + ERASED: __('Erased'), + QUEUED: __('Queued'), + RUNNER: __('Runner'), + TAGS: __('Tags:'), + TIMEOUT: __('Timeout'), + }, + 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.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">{{ 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..3fee1427256 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue @@ -0,0 +1,173 @@ +<script> +import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { Mousetrap } from '~/lib/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, + GlDisclosureDropdown, + GlLink, + GlSprintf, + }, + 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}'); + } + if (!this.isTriggeredByMergeRequest) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}'); + } + 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; + } + 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" + >#{{ 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-disclosure-dropdown + :toggle-text="selectedStage" + :items="dropdownItems" + block + class="gl-mt-3" + /> + </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..c9172fe0322 --- /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-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..ede517b2d94 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -0,0 +1,350 @@ +<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 CiHeader from '~/vue_shared/components/header_ci_component.vue'; +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 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: { + CiHeader, + 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); + }, + + itemName() { + 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"> + <ci-header + :status="job.status" + :time="headerTime" + :user="job.user" + :has-sidebar-button="true" + :should-render-triggered-label="shouldRenderTriggeredLabel" + :item-name="itemName" + @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..44182a20c4c --- /dev/null +++ b/app/assets/javascripts/ci/job_details/utils.js @@ -0,0 +1,22 @@ +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; +}; diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue new file mode 100644 index 00000000000..609f2790869 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue @@ -0,0 +1,265 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlModal, + GlModalDirective, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { reportMessageToSentry } from '~/ci/utils'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { + ACTIONS_DOWNLOAD_ARTIFACTS, + ACTIONS_START_NOW, + ACTIONS_UNSCHEDULE, + ACTIONS_PLAY, + ACTIONS_RETRY, + ACTIONS_RUN_AGAIN, + CANCEL, + GENERIC_ERROR, + JOB_SCHEDULED, + JOB_SUCCESS, + PLAY_JOB_CONFIRMATION_MESSAGE, + RUN_JOB_NOW_HEADER_TITLE, + FILE_TYPE_ARCHIVE, +} from '../../constants'; +import eventHub from '../../event_hub'; +import cancelJobMutation from '../../graphql/mutations/job_cancel.mutation.graphql'; +import playJobMutation from '../../graphql/mutations/job_play.mutation.graphql'; +import retryJobMutation from '../../graphql/mutations/job_retry.mutation.graphql'; +import unscheduleJobMutation from '../../graphql/mutations/job_unschedule.mutation.graphql'; + +export default { + ACTIONS_DOWNLOAD_ARTIFACTS, + ACTIONS_START_NOW, + ACTIONS_UNSCHEDULE, + ACTIONS_PLAY, + ACTIONS_RETRY, + CANCEL, + GENERIC_ERROR, + PLAY_JOB_CONFIRMATION_MESSAGE, + RUN_JOB_NOW_HEADER_TITLE, + jobRetry: 'jobRetry', + jobCancel: 'jobCancel', + jobPlay: 'jobPlay', + jobUnschedule: 'jobUnschedule', + playJobModalId: 'play-job-modal', + name: 'JobActionsCell', + components: { + GlButton, + GlButtonGroup, + GlCountdown, + GlModal, + GlSprintf, + }, + directives: { + GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + inject: { + admin: { + default: false, + }, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + data() { + return { + retryBtnDisabled: false, + cancelBtnDisabled: false, + playManualBtnDisabled: false, + unscheduleBtnDisabled: false, + }; + }, + computed: { + hasArtifacts() { + return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE); + }, + artifactDownloadPath() { + return this.hasArtifacts.downloadPath; + }, + canReadJob() { + return this.job.userPermissions?.readBuild; + }, + canUpdateJob() { + return this.job.userPermissions?.updateBuild; + }, + canReadArtifacts() { + return this.job.userPermissions?.readJobArtifacts; + }, + isActive() { + return this.job.active; + }, + manualJobPlayable() { + return this.job.playable && !this.admin && this.job.manualJob; + }, + isRetryable() { + return this.job.retryable; + }, + isScheduled() { + return this.job.status === JOB_SCHEDULED; + }, + scheduledAt() { + return this.job.scheduledAt; + }, + currentJobActionPath() { + return this.job.detailedStatus?.action?.path; + }, + currentJobMethod() { + return this.job.detailedStatus?.action?.method; + }, + shouldDisplayArtifacts() { + return this.canReadArtifacts && this.hasArtifacts; + }, + retryButtonTitle() { + return this.job.status === JOB_SUCCESS ? ACTIONS_RUN_AGAIN : ACTIONS_RETRY; + }, + }, + methods: { + async postJobAction(name, mutation, redirect = false) { + try { + const { + data: { + [name]: { errors, job }, + }, + } = await this.$apollo.mutate({ + mutation, + variables: { id: this.job.id }, + }); + if (errors.length > 0) { + reportMessageToSentry(this.$options.name, errors.join(', '), {}); + this.showToastMessage(); + } else if (redirect) { + // Retry and Play actions redirect to job detail view + // we don't need to refetch with jobActionPerformed event + redirectTo(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated + } else { + eventHub.$emit('jobActionPerformed'); + } + } catch (failure) { + reportMessageToSentry(this.$options.name, failure, {}); + this.showToastMessage(); + } + }, + showToastMessage() { + const toastProps = { + text: this.$options.GENERIC_ERROR, + variant: 'danger', + }; + + this.$toast.show(toastProps.text, { + variant: toastProps.variant, + }); + }, + cancelJob() { + this.cancelBtnDisabled = true; + + this.postJobAction(this.$options.jobCancel, cancelJobMutation); + }, + retryJob() { + this.retryBtnDisabled = true; + + this.postJobAction(this.$options.jobRetry, retryJobMutation, true); + }, + playJob() { + this.playManualBtnDisabled = true; + + this.postJobAction(this.$options.jobPlay, playJobMutation, true); + }, + unscheduleJob() { + this.unscheduleBtnDisabled = true; + + this.postJobAction(this.$options.jobUnschedule, unscheduleJobMutation); + }, + }, +}; +</script> + +<template> + <gl-button-group> + <template v-if="canReadJob && canUpdateJob"> + <gl-button + v-if="isActive" + v-gl-tooltip + icon="cancel" + :title="$options.CANCEL" + :aria-label="$options.CANCEL" + :disabled="cancelBtnDisabled" + data-testid="cancel-button" + @click="cancelJob()" + /> + <template v-else-if="isScheduled"> + <gl-button icon="planning" disabled data-testid="countdown"> + <gl-countdown :end-date-string="scheduledAt" /> + </gl-button> + <gl-button + v-gl-modal-directive="$options.playJobModalId" + v-gl-tooltip + icon="play" + :title="$options.ACTIONS_START_NOW" + :aria-label="$options.ACTIONS_START_NOW" + data-testid="play-scheduled" + /> + <gl-modal + :modal-id="$options.playJobModalId" + :title="$options.RUN_JOB_NOW_HEADER_TITLE" + @primary="playJob()" + > + <gl-sprintf :message="$options.PLAY_JOB_CONFIRMATION_MESSAGE"> + <template #job_name>{{ job.name }}</template> + </gl-sprintf> + </gl-modal> + <gl-button + v-gl-tooltip + icon="time-out" + :title="$options.ACTIONS_UNSCHEDULE" + :aria-label="$options.ACTIONS_UNSCHEDULE" + :disabled="unscheduleBtnDisabled" + data-testid="unschedule" + @click="unscheduleJob()" + /> + </template> + <template v-else> + <!--Note: This is the manual job play button --> + <gl-button + v-if="manualJobPlayable" + v-gl-tooltip + icon="play" + :title="$options.ACTIONS_PLAY" + :aria-label="$options.ACTIONS_PLAY" + :disabled="playManualBtnDisabled" + data-testid="play" + @click="playJob()" + /> + <gl-button + v-else-if="isRetryable" + v-gl-tooltip + icon="retry" + :title="retryButtonTitle" + :aria-label="retryButtonTitle" + :method="currentJobMethod" + :disabled="retryBtnDisabled" + data-testid="retry" + @click="retryJob()" + /> + </template> + </template> + <gl-button + v-if="shouldDisplayArtifacts" + v-gl-tooltip + icon="download" + :title="$options.ACTIONS_DOWNLOAD_ARTIFACTS" + :aria-label="$options.ACTIONS_DOWNLOAD_ARTIFACTS" + :href="artifactDownloadPath" + rel="nofollow" + download + data-testid="download-artifacts" + /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue new file mode 100644 index 00000000000..dbf1dfe7a29 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue @@ -0,0 +1,52 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { formatTime } from '~/lib/utils/datetime_utility'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + iconSize: 12, + components: { + GlIcon, + TimeAgoTooltip, + }, + mixins: [timeagoMixin], + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + finishedTime() { + return this.job?.finishedAt; + }, + duration() { + return this.job?.duration; + }, + durationFormatted() { + return formatTime(this.duration * 1000); + }, + hasDurationAndFinishedTime() { + return this.finishedTime && this.duration; + }, + }, +}; +</script> + +<template> + <div> + <div v-if="duration" data-testid="job-duration"> + <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> + {{ durationFormatted }} + </div> + <div + v-if="finishedTime" + :class="{ 'gl-mt-2': hasDurationAndFinishedTime }" + data-testid="job-finished-time" + > + <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> + <time-ago-tooltip :time="finishedTime" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue new file mode 100644 index 00000000000..b435eb283fd --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue @@ -0,0 +1,171 @@ +<script> +import { GlBadge, GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { s__ } from '~/locale'; +import { SUCCESS_STATUS } from '../../../constants'; + +export default { + iconSize: 12, + badgeSize: 'sm', + i18n: { + stuckText: s__('Jobs|Job is stuck. Check runners.'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlBadge, + GlIcon, + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + jobId() { + const id = getIdFromGraphQLId(this.job.id); + return `#${id}`; + }, + jobPath() { + return this.job.detailedStatus?.detailsPath; + }, + jobRef() { + return this.job?.refName; + }, + jobRefPath() { + return this.job?.refPath; + }, + jobTags() { + return this.job.tags; + }, + createdByTag() { + return this.job.createdByTag; + }, + triggered() { + return this.job.triggered; + }, + isManualJob() { + return this.job.manualJob; + }, + successfulJob() { + return this.job.status === SUCCESS_STATUS; + }, + showAllowedToFailBadge() { + return this.job.allowFailure && !this.successfulJob; + }, + isScheduledJob() { + return Boolean(this.job.scheduledAt); + }, + canReadJob() { + return this.job?.userPermissions?.readBuild; + }, + jobStuck() { + return this.job?.stuck; + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-text-truncate gl-p-3 gl-mt-n3 gl-mx-n3 gl-mb-n2"> + <gl-link + v-if="canReadJob" + class="gl-text-blue-600!" + :href="jobPath" + data-testid="job-id-link" + > + {{ jobId }} + </gl-link> + + <span v-else data-testid="job-id-limited-access">{{ jobId }}</span> + + <gl-icon + v-if="jobStuck" + v-gl-tooltip="$options.i18n.stuckText" + name="warning" + :size="$options.iconSize" + data-testid="stuck-icon" + /> + + <div + class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-2" + > + <div + v-if="jobRef" + class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate" + > + <gl-icon + v-if="createdByTag" + name="label" + :size="$options.iconSize" + data-testid="label-icon" + /> + <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.refPath" + data-testid="job-ref" + >{{ job.refName }}</gl-link + > + </div> + + <span v-else>{{ __('none') }}</span> + <div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50"> + <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.commitPath" + data-testid="job-sha" + >{{ job.shortSha }}</gl-link + > + </div> + </div> + </div> + + <div> + <gl-badge + v-for="tag in jobTags" + :key="tag" + variant="info" + :size="$options.badgeSize" + data-testid="job-tag-badge" + > + {{ tag }} + </gl-badge> + + <gl-badge + v-if="triggered" + variant="info" + :size="$options.badgeSize" + data-testid="triggered-job-badge" + >{{ s__('Job|triggered') }} + </gl-badge> + <gl-badge + v-if="showAllowedToFailBadge" + variant="warning" + :size="$options.badgeSize" + data-testid="fail-job-badge" + >{{ s__('Job|allowed to fail') }} + </gl-badge> + <gl-badge + v-if="isScheduledJob" + variant="info" + :size="$options.badgeSize" + data-testid="delayed-job-badge" + >{{ s__('Job|delayed') }} + </gl-badge> + <gl-badge + v-if="isManualJob" + variant="info" + :size="$options.badgeSize" + data-testid="manual-job-badge" + > + {{ s__('Job|manual') }} + </gl-badge> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue new file mode 100644 index 00000000000..18d68ee8a29 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue @@ -0,0 +1,56 @@ +<script> +import { GlAvatar, GlLink } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + components: { + GlAvatar, + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + pipelineId() { + const id = getIdFromGraphQLId(this.job.pipeline.id); + return `#${id}`; + }, + pipelinePath() { + return this.job.pipeline?.path; + }, + pipelineUserAvatar() { + return this.job.pipeline?.user?.avatarUrl; + }, + userPath() { + return this.job.pipeline?.user?.webPath; + }, + showAvatar() { + return this.job.pipeline?.user; + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-p-3 gl-mt-n3"> + <gl-link + class="gl-text-truncate gl-ml-n3 gl-text-gray-500!" + :href="pipelinePath" + data-testid="pipeline-id" + > + {{ pipelineId }} + </gl-link> + </div> + <div class="gl-font-sm gl-text-secondary gl-mt-n2"> + <span>{{ __('created by') }}</span> + <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> + <gl-avatar :src="pipelineUserAvatar" :size="16" /> + </gl-link> + <span v-else>{{ __('API') }}</span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue new file mode 100644 index 00000000000..9a66e4698df --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue @@ -0,0 +1,112 @@ +<script> +import { GlTable } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import ProjectCell from '~/pages/admin/jobs/components/table/cell/project_cell.vue'; +import RunnerCell from '~/pages/admin/jobs/components/table/cells/runner_cell.vue'; +import { DEFAULT_FIELDS } from '../constants'; +import ActionsCell from './job_cells/actions_cell.vue'; +import DurationCell from './job_cells/duration_cell.vue'; +import JobCell from './job_cells/job_cell.vue'; +import PipelineCell from './job_cells/pipeline_cell.vue'; + +export default { + i18n: { + emptyText: s__('Jobs|No jobs to show'), + }, + components: { + ActionsCell, + CiBadgeLink, + DurationCell, + GlTable, + JobCell, + PipelineCell, + ProjectCell, + RunnerCell, + }, + props: { + jobs: { + type: Array, + required: true, + }, + tableFields: { + type: Array, + required: false, + default: () => DEFAULT_FIELDS, + }, + admin: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + formatCoverage(coverage) { + return coverage ? `${coverage}%` : ''; + }, + }, +}; +</script> + +<template> + <gl-table + :items="jobs" + :fields="tableFields" + :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" + :empty-text="$options.i18n.emptyText" + data-testid="jobs-table" + show-empty + stacked="lg" + fixed + > + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(status)="{ item }"> + <ci-badge-link :status="item.detailedStatus" /> + </template> + + <template #cell(job)="{ item }"> + <job-cell :job="item" /> + </template> + + <template #cell(pipeline)="{ item }"> + <pipeline-cell :job="item" /> + </template> + + <template v-if="admin" #cell(project)="{ item }"> + <project-cell :job="item" /> + </template> + + <template v-if="admin" #cell(runner)="{ item }"> + <runner-cell :job="item" /> + </template> + + <template #cell(stage)="{ item }"> + <div class="gl-text-truncate"> + <span v-if="item.stage" data-testid="job-stage-name">{{ item.stage.name }}</span> + </div> + </template> + + <template #cell(name)="{ item }"> + <div class="gl-text-truncate"> + <span data-testid="job-name">{{ item.name }}</span> + </div> + </template> + + <template #cell(duration)="{ item }"> + <duration-cell :job="item" /> + </template> + + <template #cell(coverage)="{ item }"> + <span v-if="item.coverage" data-testid="job-coverage">{{ + formatCoverage(item.coverage) + }}</span> + </template> + + <template #cell(actions)="{ item }"> + <actions-cell class="gl-float-right" :job="item" /> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue new file mode 100644 index 00000000000..fcdd52b719c --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue @@ -0,0 +1,35 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('Jobs|Use jobs to automate your tasks'), + description: s__( + 'Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.', + ), + buttonText: s__('Jobs|Create CI/CD configuration file'), + }, + components: { + GlEmptyState, + }, + inject: { + pipelineEditorPath: { + default: '', + }, + emptyStateSvgPath: { + default: '', + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :description="$options.i18n.description" + :svg-path="emptyStateSvgPath" + :primary-button-link="pipelineEditorPath" + :primary-button-text="$options.i18n.buttonText" + /> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue new file mode 100644 index 00000000000..464b500c66e --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue @@ -0,0 +1,88 @@ +<script> +import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import CancelJobs from '~/pages/admin/jobs/components/cancel_jobs.vue'; +import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; + +export default { + components: { + GlBadge, + GlTab, + GlTabs, + GlLoadingIcon, + CancelJobs, + }, + inject: { + jobStatuses: { + default: {}, + }, + url: { + type: String, + default: '', + }, + }, + props: { + allJobsCount: { + type: Number, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + showCancelAllJobsButton: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + tabs() { + return [ + { + text: s__('Jobs|All'), + count: limitedCounterWithDelimiter(this.allJobsCount), + scope: null, + testId: 'jobs-all-tab', + showBadge: true, + }, + { + text: s__('Jobs|Finished'), + scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled], + testId: 'jobs-finished-tab', + showBadge: false, + }, + ]; + }, + showLoadingIcon() { + return this.loading && !this.allJobsCount; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex align-items-lg-center"> + <gl-tabs content-class="gl-py-0" class="gl-w-full"> + <gl-tab + v-for="tab in tabs" + :key="tab.text" + :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + 'data-testid': tab.testId, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + @click="$emit('fetchJobsByStatus', tab.scope)" + > + <template #title> + <span>{{ tab.text }}</span> + <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" /> + + <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + <div class="gl-flex-grow-1"></div> + <cancel-jobs v-if="showCancelAllJobsButton" :url="url" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/constants.js b/app/assets/javascripts/ci/jobs_page/constants.js new file mode 100644 index 00000000000..1b572e60c58 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/constants.js @@ -0,0 +1,76 @@ +import { s__, __ } from '~/locale'; + +/* Error constants */ +export const DEFAULT = 'default'; +export const RAW_TEXT_WARNING = s__( + 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', +); + +/* Job Status Constants */ +export const JOB_SCHEDULED = 'SCHEDULED'; +export const JOB_SUCCESS = 'SUCCESS'; + +/* Artifact file types */ +export const FILE_TYPE_ARCHIVE = 'ARCHIVE'; + +/* i18n */ +export const ACTIONS_DOWNLOAD_ARTIFACTS = __('Download artifacts'); +export const ACTIONS_START_NOW = s__('DelayedJobs|Start now'); +export const ACTIONS_UNSCHEDULE = s__('DelayedJobs|Unschedule'); +export const ACTIONS_PLAY = __('Play'); +export const ACTIONS_RETRY = __('Retry'); +export const ACTIONS_RUN_AGAIN = __('Run again'); + +export const CANCEL = __('Cancel'); +export const GENERIC_ERROR = __('An error occurred while making the request.'); +export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( + `DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`, +); +export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); + +/* Table constants */ +export const DEFAULT_FIELDS = [ + { + key: 'status', + label: __('Status'), + columnClass: 'gl-w-10p', + }, + { + key: 'job', + label: __('Job'), + columnClass: 'gl-w-20p', + }, + { + key: 'pipeline', + label: __('Pipeline'), + columnClass: 'gl-w-10p', + }, + { + key: 'stage', + label: __('Stage'), + columnClass: 'gl-w-10p', + }, + { + key: 'name', + label: __('Name'), + columnClass: 'gl-w-15p', + }, + { + key: 'duration', + label: __('Duration'), + columnClass: 'gl-w-15p', + }, + { + key: 'coverage', + label: __('Coverage'), + tdClass: 'gl-display-none! gl-lg-display-table-cell!', + columnClass: 'gl-w-10p', + }, + { + key: 'actions', + label: '', + columnClass: 'gl-w-10p', + }, +]; + +export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline'); diff --git a/app/assets/javascripts/ci/jobs_page/event_hub.js b/app/assets/javascripts/ci/jobs_page/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js b/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js new file mode 100644 index 00000000000..5390c023da4 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js @@ -0,0 +1,60 @@ +import { isEqual } from 'lodash'; + +export default { + typePolicies: { + Project: { + fields: { + jobs: { + keyArgs: false, + }, + }, + }, + CiJobConnection: { + merge(existing = {}, incoming, { args = {} }) { + if (incoming.nodes) { + let nodes; + + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } + } else { + nodes = [...incoming.nodes]; + } + + return { + nodes, + statuses, + pageInfo, + }; + } + + return { + nodes: existing.nodes, + pageInfo: existing.pageInfo, + statuses: args.statuses, + }; + }, + }, + }, +}; diff --git a/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql b/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql new file mode 100644 index 00000000000..3038216fdfc --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql @@ -0,0 +1,7 @@ +fragment Job on CiJob { + id + detailedStatus { + id + detailsPath + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql new file mode 100644 index 00000000000..20935514d51 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation cancelJob($id: CiBuildID!) { + jobCancel(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql new file mode 100644 index 00000000000..c94b045ac40 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation playJob($id: CiBuildID!) { + jobPlay(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql new file mode 100644 index 00000000000..6e51f9a20fa --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation retryJob($id: CiBuildID!) { + jobRetry(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql new file mode 100644 index 00000000000..8be8c42f3c3 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation unscheduleJob($id: CiBuildID!) { + jobUnschedule(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql new file mode 100644 index 00000000000..69719011079 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql @@ -0,0 +1,78 @@ +query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) { + project(fullPath: $fullPath) { + id + jobs(after: $after, first: $first, statuses: $statuses) { + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + nodes { + artifacts { + # eslint-disable-next-line @graphql-eslint/require-id-when-available + nodes { + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + pipeline { + id + path + user { + id + webPath + avatarUrl + } + } + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql new file mode 100644 index 00000000000..a4e02ae721a --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql @@ -0,0 +1,8 @@ +query getJobsCount($fullPath: ID!, $statuses: [CiJobStatus!]) { + project(fullPath: $fullPath) { + id + jobs(statuses: $statuses) { + count + } + } +} diff --git a/app/assets/javascripts/ci/jobs_page/index.js b/app/assets/javascripts/ci/jobs_page/index.js new file mode 100644 index 00000000000..7e99157289b --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/index.js @@ -0,0 +1,50 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import JobsTableApp from '~/ci/jobs_page/jobs_page_app.vue'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import cacheConfig from './graphql/cache_config'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + cacheConfig, + }, + ), +}); + +export default (containerId = 'js-jobs-table') => { + const containerEl = document.getElementById(containerId); + + if (!containerEl) { + return false; + } + + const { + fullPath, + jobStatuses, + pipelineEditorPath, + emptyStateSvgPath, + admin, + } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + emptyStateSvgPath, + fullPath, + pipelineEditorPath, + jobStatuses: JSON.parse(jobStatuses), + admin: parseBoolean(admin), + }, + render(createElement) { + return createElement(JobsTableApp); + }, + }); +}; diff --git a/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue new file mode 100644 index 00000000000..c801b35e868 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue @@ -0,0 +1,238 @@ +<script> +import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { createAlert } from '~/alert'; +import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; +import JobsSkeletonLoader from '~/pages/admin/jobs/components/jobs_skeleton_loader.vue'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; +import { validateQueryString } from '~/ci/common/private/jobs_filtered_search/utils'; +import GetJobs from './graphql/queries/get_jobs.query.graphql'; +import GetJobsCount from './graphql/queries/get_jobs_count.query.graphql'; +import JobsTable from './components/jobs_table.vue'; +import JobsTableEmptyState from './components/jobs_table_empty_state.vue'; +import JobsTableTabs from './components/jobs_table_tabs.vue'; +import { RAW_TEXT_WARNING } from './constants'; + +export default { + i18n: { + jobsFetchErrorMsg: __('There was an error fetching the jobs for your project.'), + jobsCountErrorMsg: __('There was an error fetching the number of jobs for your project.'), + loadingAriaLabel: __('Loading'), + }, + filterSearchBoxStyles: + 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100', + components: { + GlAlert, + JobsFilteredSearch, + JobsTable, + JobsTableEmptyState, + JobsTableTabs, + GlIntersectionObserver, + GlLoadingIcon, + JobsSkeletonLoader, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + jobs: { + query: GetJobs, + variables() { + return { + fullPath: this.fullPath, + ...this.validatedQueryString, + }; + }, + update(data) { + const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + return { + list, + pageInfo, + }; + }, + error() { + this.error = this.$options.i18n.jobsFetchErrorMsg; + }, + }, + jobsCount: { + query: GetJobsCount, + context: { + isSingleRequest: true, + }, + variables() { + return { + fullPath: this.fullPath, + ...this.validatedQueryString, + }; + }, + update({ project }) { + return project?.jobs?.count || 0; + }, + error() { + this.error = this.$options.i18n.jobsCountErrorMsg; + }, + }, + }, + data() { + return { + jobs: { + list: [], + }, + error: '', + scope: null, + infiniteScrollingTriggered: false, + filterSearchTriggered: false, + jobsCount: null, + count: 0, + }; + }, + computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, + // Show when on All tab with no jobs + // Show only when not loading and filtered search has not been triggered + // So we don't show empty state when results are empty on a filtered search + showEmptyState() { + return ( + this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered + ); + }, + hasNextPage() { + return this.jobs?.pageInfo?.hasNextPage; + }, + showLoadingSpinner() { + return this.loading && this.infiniteScrollingTriggered; + }, + showSkeletonLoader() { + return this.loading && !this.showLoadingSpinner; + }, + showFilteredSearch() { + return !this.scope; + }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount, oldCount) { + if (this.scope) { + this.count = oldCount; + } else { + this.count = newCount; + } + }, + }, + methods: { + updateHistoryAndFetchCount(status = null) { + this.$apollo.queries.jobsCount.refetch({ statuses: status }); + + updateHistory({ + url: setUrlParams({ statuses: status }, window.location.href, true), + }); + }, + fetchJobsByStatus(scope) { + this.infiniteScrollingTriggered = false; + + if (this.scope === scope) return; + + this.scope = scope; + + if (!this.scope) this.updateHistoryAndFetchCount(); + + this.$apollo.queries.jobs.refetch({ statuses: scope }); + }, + filterJobsBySearch(filters) { + this.infiniteScrollingTriggered = false; + this.filterSearchTriggered = true; + + // all filters have been cleared reset query param + // and refetch jobs/count with defaults + if (!filters.length) { + this.updateHistoryAndFetchCount(); + this.$apollo.queries.jobs.refetch({ statuses: null }); + + return; + } + + // Eventually there will be more tokens available + // this code is written to scale for those tokens + filters.forEach((filter) => { + // Raw text input in filtered search does not have a type + // when a user enters raw text we alert them that it is + // not supported and we do not make an additional API call + if (!filter.type) { + createAlert({ + message: RAW_TEXT_WARNING, + type: 'warning', + }); + } + + if (filter.type === 'status') { + this.updateHistoryAndFetchCount(filter.value.data); + this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); + } + }); + }, + fetchMoreJobs() { + if (!this.loading) { + this.infiniteScrollingTriggered = true; + + this.$apollo.queries.jobs.fetchMore({ + variables: { + fullPath: this.fullPath, + after: this.jobs?.pageInfo?.endCursor, + }, + }); + } + }, + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="error" + class="gl-mt-2" + variant="danger" + data-testid="jobs-table-error-alert" + dismissible + @dismiss="error = ''" + > + {{ error }} + </gl-alert> + + <jobs-table-tabs + :all-jobs-count="count" + :loading="loading" + @fetchJobsByStatus="fetchJobsByStatus" + /> + <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles"> + <jobs-filtered-search + :query-string="validatedQueryString" + @filterJobsBySearch="filterJobsBySearch" + /> + </div> + + <jobs-skeleton-loader v-if="showSkeletonLoader" class="gl-mt-5" /> + + <jobs-table-empty-state v-else-if="showEmptyState" /> + + <jobs-table v-else :jobs="jobs.list" class="gl-table-no-top-border" /> + + <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon + v-if="showLoadingSpinner" + size="lg" + :aria-label="$options.i18n.loadingAriaLabel" + /> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/ci/mixins/delayed_job_mixin.js b/app/assets/javascripts/ci/mixins/delayed_job_mixin.js new file mode 100644 index 00000000000..7b17dc7f693 --- /dev/null +++ b/app/assets/javascripts/ci/mixins/delayed_job_mixin.js @@ -0,0 +1,53 @@ +import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; + +export default { + data() { + return { + remainingTime: formatTime(0), + remainingTimeIntervalId: null, + }; + }, + + mounted() { + this.startRemainingTimeInterval(); + }, + + beforeDestroy() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + }, + + computed: { + isDelayedJob() { + return this.job?.scheduled || this.job?.scheduledAt; + }, + scheduledTime() { + return this.job.scheduled_at || this.job.scheduledAt; + }, + }, + + watch: { + isDelayedJob() { + this.startRemainingTimeInterval(); + }, + }, + + methods: { + startRemainingTimeInterval() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + + if (this.isDelayedJob) { + this.updateRemainingTime(); + this.remainingTimeIntervalId = setInterval(() => this.updateRemainingTime(), 1000); + } + }, + + updateRemainingTime() { + const remainingMilliseconds = calculateRemainingMilliseconds(this.scheduledTime); + this.remainingTime = formatTime(remainingMilliseconds); + }, + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue index bab05d0c232..4298052d1c0 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue @@ -1,7 +1,7 @@ <script> import { GlBadge, GlForm, GlFormCheckbox, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui'; import { reportToSentry } from '~/ci/utils'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue b/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue index 7a09dd27ad8..81b6152347d 100644 --- a/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue +++ b/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue @@ -3,9 +3,9 @@ import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab import produce from 'immer'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; -import eventHub from '~/jobs/components/table/event_hub'; -import JobsTable from '~/jobs/components/table/jobs_table.vue'; -import { JOBS_TAB_FIELDS } from '~/jobs/components/table/constants'; +import eventHub from '~/ci/jobs_page/event_hub'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import { JOBS_TAB_FIELDS } from '~/ci/jobs_page/constants'; import getPipelineJobs from './graphql/queries/get_pipeline_jobs.query.graphql'; export default { diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue index 27917d029b3..6da4ff2b0c2 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue @@ -3,9 +3,9 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import ActionComponent from '~/ci/common/private/job_action_component.vue'; import JobNameComponent from '~/ci/common/private/job_name_component.vue'; import { ICONS } from '~/ci/pipeline_details/constants'; -import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { s__, sprintf } from '~/locale'; -import { reportToSentry } from '../utils'; +import { reportToSentry } from '~/ci/utils'; /** * Renders the badge for the pipeline graph and the job's dropdown. |