diff options
Diffstat (limited to 'app/assets/javascripts/ci/pipeline_editor/components/header')
4 files changed, 476 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue new file mode 100644 index 00000000000..ec6ee52b6b2 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue @@ -0,0 +1,70 @@ +<script> +import PipelineStatus from './pipeline_status.vue'; +import ValidationSegment from './validation_segment.vue'; + +const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100']; + +const pipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-border-b-0!', + 'gl-rounded-top-base', +]; + +const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base']; + +const validationSegmentWithPipelineStatusClasses = [ + ...baseClasses, + 'gl-border-1', + 'gl-rounded-bottom-left-base', + 'gl-rounded-bottom-right-base', +]; + +export default { + pipelineStatusClasses, + validationSegmentClasses, + validationSegmentWithPipelineStatusClasses, + components: { + PipelineStatus, + ValidationSegment, + }, + props: { + ciConfigData: { + type: Object, + required: true, + }, + commitSha: { + type: String, + required: false, + default: '', + }, + isNewCiConfigFile: { + type: Boolean, + required: true, + }, + }, + computed: { + showPipelineStatus() { + return !this.isNewCiConfigFile; + }, + // make sure corners are rounded correctly depending on if + // pipeline status is rendered + validationStyling() { + return this.showPipelineStatus + ? this.$options.validationSegmentWithPipelineStatusClasses + : this.$options.validationSegmentClasses; + }, + }, +}; +</script> +<template> + <div class="gl-mb-5"> + <pipeline-status + v-if="showPipelineStatus" + :commit-sha="commitSha" + :class="$options.pipelineStatusClasses" + v-on="$listeners" + /> + <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue new file mode 100644 index 00000000000..feadc60a22a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -0,0 +1,92 @@ +<script> +import { __ } from '~/locale'; +import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; +import { PIPELINE_FAILURE } from '../../constants'; + +export default { + i18n: { + linkedPipelinesFetchError: __('Unable to fetch upstream and downstream pipelines.'), + }, + components: { + PipelineMiniGraph, + }, + inject: ['projectFullPath'], + props: { + pipeline: { + type: Object, + required: true, + }, + }, + apollo: { + linkedPipelines: { + query: getLinkedPipelinesQuery, + variables() { + return { + fullPath: this.projectFullPath, + iid: this.pipeline.iid, + }; + }, + skip() { + return !this.pipeline.iid; + }, + update({ project }) { + return project?.pipeline; + }, + error() { + this.$emit('showError', { + type: PIPELINE_FAILURE, + reasons: [this.$options.i18n.linkedPipelinesFetchError], + }); + }, + }, + }, + computed: { + downstreamPipelines() { + return this.linkedPipelines?.downstream?.nodes || []; + }, + hasPipelineStages() { + return this.pipelineStages.length > 0; + }, + pipelinePath() { + return this.pipeline.detailedStatus?.detailsPath || ''; + }, + pipelineStages() { + const stages = this.pipeline.stages?.edges; + if (!stages) { + return []; + } + + return stages.map(({ node }) => { + const { name, detailedStatus } = node; + return { + // TODO: fetch dropdown_path from graphql when available + // see https://gitlab.com/gitlab-org/gitlab/-/issues/342585 + dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`, + name, + path: `${this.pipelinePath}#${name}`, + status: { + details_path: `${this.pipelinePath}#${name}`, + has_details: detailedStatus.hasDetails, + ...detailedStatus, + }, + title: `${name}: ${detailedStatus.text}`, + }; + }); + }, + upstreamPipeline() { + return this.linkedPipelines?.upstream; + }, + }, +}; +</script> + +<template> + <pipeline-mini-graph + v-if="hasPipelineStages" + :downstream-pipelines="downstreamPipelines" + :pipeline-path="pipelinePath" + :stages="pipelineStages" + :upstream-pipeline="upstreamPipeline" + /> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue new file mode 100644 index 00000000000..372f04075ab --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -0,0 +1,188 @@ +<script> +import { GlButton, GlIcon, GlLink, GlLoadingIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { truncateSha } from '~/lib/utils/text_utility'; +import { s__ } from '~/locale'; +import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; +import { + getQueryHeaders, + toggleQueryPollingByVisibility, +} from '~/pipelines/components/graph/utils'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; + +const POLL_INTERVAL = 10000; +export const i18n = { + fetchError: s__('Pipeline|We are currently unable to fetch pipeline data'), + fetchLoading: s__('Pipeline|Checking pipeline status'), + pipelineInfo: s__( + `Pipeline|Pipeline %{idStart}#%{idEnd} %{statusStart}%{statusEnd} for %{commitStart}%{commitEnd}`, + ), + viewBtn: s__('Pipeline|View pipeline'), + viewCommit: s__('Pipeline|View commit'), +}; + +export default { + i18n, + components: { + CiIcon, + GlButton, + GlIcon, + GlLink, + GlLoadingIcon, + GlSprintf, + PipelineEditorMiniGraph, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectFullPath'], + props: { + commitSha: { + type: String, + required: false, + default: '', + }, + }, + apollo: { + pipelineEtag: { + query: getPipelineEtag, + update(data) { + return data.etags?.pipeline; + }, + }, + pipeline: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getPipelineQuery, + variables() { + return { + fullPath: this.projectFullPath, + sha: this.commitSha, + }; + }, + update(data) { + const { id, iid, commit = {}, detailedStatus = {}, stages, status } = + data.project?.pipeline || {}; + + return { + id, + iid, + commit, + detailedStatus, + stages, + status, + }; + }, + result(res) { + if (res.data?.project?.pipeline) { + this.hasError = false; + } + }, + error() { + this.hasError = true; + }, + pollInterval: POLL_INTERVAL, + }, + }, + data() { + return { + hasError: false, + }; + }, + computed: { + commitText() { + const shortSha = truncateSha(this.commitSha); + const commitTitle = this.pipeline.commit.title || ''; + + if (commitTitle.length > 0) { + return `${shortSha}: ${commitTitle}`; + } + + return shortSha; + }, + hasPipelineData() { + return Boolean(this.pipeline?.id); + }, + pipelineId() { + return getIdFromGraphQLId(this.pipeline.id); + }, + showLoadingState() { + // the query is set to poll regularly, so if there is no pipeline data + // (e.g. pipeline is null during fetch when the pipeline hasn't been + // triggered yet), we can just show the loading state until the pipeline + // details are ready to be fetched + return ( + this.$apollo.queries.pipeline.loading || + this.commitSha.length === 0 || + (!this.hasPipelineData && !this.hasError) + ); + }, + shortSha() { + return truncateSha(this.commitSha); + }, + status() { + return this.pipeline.detailedStatus; + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline, POLL_INTERVAL); + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap"> + <template v-if="showLoadingState"> + <div> + <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> + <span data-testid="pipeline-loading-msg">{{ $options.i18n.fetchLoading }}</span> + </div> + </template> + <template v-else-if="hasError"> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + </template> + <template v-else> + <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> + <a :href="status.detailsPath" class="gl-mr-auto"> + <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" /> + </a> + <span class="gl-font-weight-bold"> + <gl-sprintf :message="$options.i18n.pipelineInfo"> + <template #id="{ content }"> + <span data-testid="pipeline-id" data-qa-selector="pipeline_id_content"> + {{ content }}{{ pipelineId }} + </span> + </template> + <template #status>{{ status.text }}</template> + <template #commit> + <gl-link + v-gl-tooltip.hover + :href="pipeline.commit.webPath" + :title="$options.i18n.viewCommit" + data-testid="pipeline-commit" + > + {{ commitText }} + </gl-link> + </template> + </gl-sprintf> + </span> + </div> + <div class="gl-display-flex gl-flex-wrap"> + <pipeline-editor-mini-graph :pipeline="pipeline" v-on="$listeners" /> + <gl-button + class="gl-ml-3" + category="secondary" + variant="confirm" + :href="status.detailsPath" + data-testid="pipeline-view-btn" + > + {{ $options.i18n.viewBtn }} + </gl-button> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue new file mode 100644 index 00000000000..84c0eef441f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue @@ -0,0 +1,126 @@ +<script> +import { GlIcon, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { + EDITOR_APP_STATUS_EMPTY, + EDITOR_APP_STATUS_LINT_UNAVAILABLE, + EDITOR_APP_STATUS_LOADING, + EDITOR_APP_STATUS_VALID, +} from '../../constants'; + +export const i18n = { + empty: __( + "We'll continuously validate your pipeline configuration. The validation results will appear here.", + ), + learnMore: __('Learn more'), + loading: s__('Pipelines|Validating GitLab CI configuration…'), + invalid: s__('Pipelines|This GitLab CI configuration is invalid.'), + invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'), + unavailableValidation: s__('Pipelines|Configuration validation currently not available.'), + valid: s__('Pipelines|Pipeline syntax is correct.'), +}; + +export default { + i18n, + components: { + GlIcon, + GlLink, + GlLoadingIcon, + TooltipOnTruncate, + }, + inject: { + lintUnavailableHelpPagePath: { + default: '', + }, + ymlHelpPagePath: { + default: '', + }, + }, + props: { + ciConfig: { + type: Object, + required: false, + default: () => ({}), + }, + }, + apollo: { + appStatus: { + query: getAppStatus, + update(data) { + return data.app.status; + }, + }, + }, + computed: { + helpPath() { + return this.isLintUnavailable ? this.lintUnavailableHelpPagePath : this.ymlHelpPagePath; + }, + isEmpty() { + return this.appStatus === EDITOR_APP_STATUS_EMPTY; + }, + isLintUnavailable() { + return this.appStatus === EDITOR_APP_STATUS_LINT_UNAVAILABLE; + }, + isLoading() { + return this.appStatus === EDITOR_APP_STATUS_LOADING; + }, + isValid() { + return this.appStatus === EDITOR_APP_STATUS_VALID; + }, + icon() { + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return 'check'; + case EDITOR_APP_STATUS_LINT_UNAVAILABLE: + return 'time-out'; + case EDITOR_APP_STATUS_VALID: + return 'check'; + default: + return 'warning-solid'; + } + }, + message() { + const [reason] = this.ciConfig?.errors || []; + + switch (this.appStatus) { + case EDITOR_APP_STATUS_EMPTY: + return this.$options.i18n.empty; + case EDITOR_APP_STATUS_LINT_UNAVAILABLE: + return this.$options.i18n.unavailableValidation; + case EDITOR_APP_STATUS_VALID: + return this.$options.i18n.valid; + default: + // Only display first error as a reason + return this.ciConfig?.errors?.length > 0 + ? sprintf(this.$options.i18n.invalidWithReason, { reason }, false) + : this.$options.i18n.invalid; + } + }, + }, +}; +</script> + +<template> + <div> + <template v-if="isLoading"> + <gl-loading-icon size="sm" inline /> + {{ $options.i18n.loading }} + </template> + + <span v-else class="gl-display-inline-flex gl-white-space-nowrap gl-max-w-full"> + <tooltip-on-truncate :title="message" class="gl-text-truncate"> + <gl-icon :name="icon" /> + <span data-qa-selector="validation_message_content" data-testid="validationMsg"> + {{ message }} + </span> + </tooltip-on-truncate> + <span v-if="!isEmpty" class="gl-flex-shrink-0 gl-pl-2"> + <gl-link data-testid="learnMoreLink" :href="helpPath"> + {{ $options.i18n.learnMore }} + </gl-link> + </span> + </span> + </div> +</template> |