summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/ci/pipeline_editor/components/header
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/ci/pipeline_editor/components/header')
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue70
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue92
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue188
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue126
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>