summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/pipelines
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/pipelines')
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/constants.js9
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component.vue72
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue56
-rw-r--r--app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue85
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue37
-rw-r--r--app/assets/javascripts/pipelines/components/graph/job_item.vue112
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue3
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue19
-rw-r--r--app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component.vue67
-rw-r--r--app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/graph/utils.js16
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/api.js2
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue26
-rw-r--r--app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue8
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue (renamed from app/assets/javascripts/pipelines/components/graph/action_component.vue)2
-rw-r--r--app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue (renamed from app/assets/javascripts/pipelines/components/graph/job_name_component.vue)0
-rw-r--r--app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue90
-rw-r--r--app/assets/javascripts/pipelines/components/parsing_utils.js26
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue9
-rw-r--r--app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue218
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue30
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue39
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue190
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue28
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue4
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue10
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue14
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue143
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue38
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue269
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue74
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue1
-rw-r--r--app/assets/javascripts/pipelines/components/unwrapping_utils.js48
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql5
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql1
-rw-r--r--app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql13
-rw-r--r--app/assets/javascripts/pipelines/mixins/pipelines_mixin.js2
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js40
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_dag.js7
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_graph.js13
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_header.js8
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_notification.js29
-rw-r--r--app/assets/javascripts/pipelines/pipeline_shared_client.js11
-rw-r--r--app/assets/javascripts/pipelines/pipelines_index.js6
-rw-r--r--app/assets/javascripts/pipelines/utils.js8
52 files changed, 1196 insertions, 721 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index e44dedfe2ee..16fb931ec2b 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -50,6 +50,10 @@ export default {
};
},
update(data) {
+ if (!data?.project?.pipeline) {
+ return this.graphData;
+ }
+
const {
stages: { nodes: stages },
} = data.project.pipeline;
diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js
index caa269f5095..dd9cdae518f 100644
--- a/app/assets/javascripts/pipelines/components/graph/constants.js
+++ b/app/assets/javascripts/pipelines/components/graph/constants.js
@@ -10,3 +10,12 @@ export const ONE_COL_WIDTH = 180;
export const REST = 'rest';
export const GRAPHQL = 'graphql';
+
+export const STAGE_VIEW = 'stage';
+export const LAYER_VIEW = 'layer';
+export const VIEW_TYPE_KEY = 'pipeline_graph_view_type';
+
+export const SINGLE_JOB = 'single_job';
+export const JOB_DROPDOWN = 'job_dropdown';
+
+export const IID_FAILURE = 'missing_iid';
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
index 363226a0d85..63048777724 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue
@@ -1,10 +1,11 @@
<script>
+import { reportToSentry } from '../../utils';
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinksLayer from '../graph_shared/links_layer.vue';
-import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH } from './constants';
+import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from './constants';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue';
-import { reportToSentry, validateConfigPaths } from './utils';
+import { validateConfigPaths } from './utils';
export default {
name: 'PipelineGraph',
@@ -24,11 +25,20 @@ export default {
type: Object,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
isLinkedPipeline: {
type: Boolean,
required: false,
default: false,
},
+ pipelineLayers: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
type: {
type: String,
required: false,
@@ -44,6 +54,7 @@ export default {
data() {
return {
hoveredJobName: '',
+ hoveredSourceJobName: '',
highlightedJobs: [],
measurements: {
width: 0,
@@ -62,8 +73,8 @@ export default {
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
- graph() {
- return this.pipeline.stages;
+ layout() {
+ return this.isStageView ? this.pipeline.stages : this.generateColumnsFromLayersList();
},
hasDownstreamPipelines() {
return Boolean(this.pipeline?.downstream?.length > 0);
@@ -71,12 +82,21 @@ export default {
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
+ isStageView() {
+ return this.viewType === STAGE_VIEW;
+ },
metricsConfig() {
return {
path: this.configPaths.metricsPath,
collectMetrics: true,
};
},
+ shouldHideLinks() {
+ return this.isStageView;
+ },
+ shouldShowStageName() {
+ return !this.isStageView;
+ },
// The show downstream check prevents showing redundant linked columns
showDownstreamPipelines() {
return (
@@ -100,6 +120,26 @@ export default {
this.getMeasurements();
},
methods: {
+ generateColumnsFromLayersList() {
+ return this.pipelineLayers.map((layers, idx) => {
+ /*
+ look up the groups in each layer,
+ then add each set of layer groups to a stage-like object
+ */
+
+ const groups = layers.map((id) => {
+ const { stageIdx, groupIdx } = this.pipeline.stagesLookup[id];
+ return this.pipeline.stages?.[stageIdx]?.groups?.[groupIdx];
+ });
+
+ return {
+ name: '',
+ id: `layer-${idx}`,
+ status: { action: null },
+ groups: groups.filter(Boolean),
+ };
+ });
+ },
getMeasurements() {
this.measurements = {
width: this.$refs[this.containerId].scrollWidth,
@@ -112,6 +152,9 @@ export default {
setJob(jobName) {
this.hoveredJobName = jobName;
},
+ setSourceJob(jobName) {
+ this.hoveredSourceJobName = jobName;
+ },
slidePipelineContainer() {
this.$refs.mainPipelineContainer.scrollBy({
left: ONE_COL_WIDTH,
@@ -146,31 +189,35 @@ export default {
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:type="$options.pipelineTypeConstants.UPSTREAM"
+ :view-type="viewType"
@error="onError"
/>
</template>
<template #main>
<div :id="containerId" :ref="containerId">
<links-layer
- :pipeline-data="graph"
+ :pipeline-data="layout"
:pipeline-id="pipeline.id"
:container-id="containerId"
:container-measurements="measurements"
:highlighted-job="hoveredJobName"
:metrics-config="metricsConfig"
- :never-show-links="true"
+ :never-show-links="shouldHideLinks"
+ :view-type="viewType"
default-link-color="gl-stroke-transparent"
@error="onError"
@highlightedJobsChange="updateHighlightedJobs"
>
<stage-column-component
- v-for="stage in graph"
- :key="stage.name"
- :title="stage.name"
- :groups="stage.groups"
- :action="stage.status.action"
+ v-for="column in layout"
+ :key="column.id || column.name"
+ :name="column.name"
+ :groups="column.groups"
+ :action="column.status.action"
:highlighted-jobs="highlightedJobs"
+ :show-stage-name="shouldShowStageName"
:job-hovered="hoveredJobName"
+ :source-job-hovered="hoveredSourceJobName"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipeline.id"
@refreshPipelineGraph="$emit('refreshPipelineGraph')"
@@ -188,7 +235,8 @@ export default {
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
- @downstreamHovered="setJob"
+ :view-type="viewType"
+ @downstreamHovered="setSourceJob"
@pipelineExpandToggle="togglePipelineExpanded"
@scrollContainer="slidePipelineContainer"
@error="onError"
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
index abbf8df6eed..39d0fa8a8ca 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue
@@ -2,10 +2,10 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { escape, capitalize } from 'lodash';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
+import { reportToSentry } from '../../utils';
import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue';
import StageColumnComponentLegacy from './stage_column_component_legacy.vue';
-import { reportToSentry } from './utils';
export default {
name: 'PipelineGraphLegacy',
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
index 962f2ca2a4c..0bc6d883245 100644
--- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
+++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue
@@ -2,11 +2,16 @@
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { __ } from '~/locale';
+import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants';
+import { reportToSentry } from '../../utils';
+import { listByLayers } from '../parsing_utils';
+import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants';
import PipelineGraph from './graph_component.vue';
+import GraphViewSelector from './graph_view_selector.vue';
import {
getQueryHeaders,
- reportToSentry,
serializeLoadErrors,
toggleQueryPollingByVisibility,
unwrapPipelineData,
@@ -17,8 +22,11 @@ export default {
components: {
GlAlert,
GlLoadingIcon,
+ GraphViewSelector,
+ LocalStorageSync,
PipelineGraph,
},
+ mixins: [glFeatureFlagMixin()],
inject: {
graphqlResourceEtag: {
default: '',
@@ -35,13 +43,18 @@ export default {
},
data() {
return {
- pipeline: null,
alertType: null,
+ currentViewType: STAGE_VIEW,
+ pipeline: null,
+ pipelineLayers: null,
showAlert: false,
};
},
errorTexts: {
[DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'),
+ [IID_FAILURE]: __(
+ 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.',
+ ),
[LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
@@ -58,6 +71,9 @@ export default {
iid: this.pipelineIid,
};
},
+ skip() {
+ return !(this.pipelineProjectPath && this.pipelineIid);
+ },
update(data) {
/*
This check prevents the pipeline from being overwritten
@@ -98,6 +114,11 @@ export default {
text: this.$options.errorTexts[DRAW_FAILURE],
variant: 'danger',
};
+ case IID_FAILURE:
+ return {
+ text: this.$options.errorTexts[IID_FAILURE],
+ variant: 'info',
+ };
case LOAD_FAILURE:
return {
text: this.$options.errorTexts[LOAD_FAILURE],
@@ -123,14 +144,28 @@ export default {
*/
return this.$apollo.queries.pipeline.loading && !this.pipeline;
},
+ showGraphViewSelector() {
+ return Boolean(this.glFeatures.pipelineGraphLayersView && this.pipeline?.usesNeeds);
+ },
},
mounted() {
+ if (!this.pipelineIid) {
+ this.reportFailure({ type: IID_FAILURE, skipSentry: true });
+ }
+
toggleQueryPollingByVisibility(this.$apollo.queries.pipeline);
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
},
methods: {
+ getPipelineLayers() {
+ if (this.currentViewType === LAYER_VIEW && !this.pipelineLayers) {
+ this.pipelineLayers = listByLayers(this.pipeline);
+ }
+
+ return this.pipelineLayers;
+ },
hideAlert() {
this.showAlert = false;
this.alertType = null;
@@ -147,7 +182,11 @@ export default {
}
},
/* eslint-enable @gitlab/require-i18n-strings */
+ updateViewType(type) {
+ this.currentViewType = type;
+ },
},
+ viewTypeKey: VIEW_TYPE_KEY,
};
</script>
<template>
@@ -155,11 +194,24 @@ export default {
<gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
{{ alert.text }}
</gl-alert>
+ <local-storage-sync
+ :storage-key="$options.viewTypeKey"
+ :value="currentViewType"
+ @input="updateViewType"
+ >
+ <graph-view-selector
+ v-if="showGraphViewSelector"
+ :type="currentViewType"
+ @updateViewType="updateViewType"
+ />
+ </local-storage-sync>
<gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" />
<pipeline-graph
v-if="pipeline"
:config-paths="configPaths"
:pipeline="pipeline"
+ :pipeline-layers="getPipelineLayers()"
+ :view-type="currentViewType"
@error="reportFailure"
@refreshPipelineGraph="refreshPipelineGraph"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
new file mode 100644
index 00000000000..f33e6290e37
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/graph/graph_view_selector.vue
@@ -0,0 +1,85 @@
+<script>
+import { GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui';
+import { __ } from '~/locale';
+import { STAGE_VIEW, LAYER_VIEW } from './constants';
+
+export default {
+ name: 'GraphViewSelector',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlIcon,
+ GlSprintf,
+ },
+ props: {
+ type: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ currentViewType: STAGE_VIEW,
+ };
+ },
+ i18n: {
+ labelText: __('Order jobs by'),
+ },
+ views: {
+ [STAGE_VIEW]: {
+ type: STAGE_VIEW,
+ text: {
+ primary: __('Stage'),
+ secondary: __('View the jobs grouped into stages'),
+ },
+ },
+ [LAYER_VIEW]: {
+ type: LAYER_VIEW,
+ text: {
+ primary: __('%{codeStart}needs:%{codeEnd} relationships'),
+ secondary: __('View what jobs are needed for a job to run'),
+ },
+ },
+ },
+ computed: {
+ currentDropdownText() {
+ return this.$options.views[this.type].text.primary;
+ },
+ },
+ methods: {
+ itemClick(type) {
+ this.$emit('updateViewType', type);
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-align-items-center gl-my-4">
+ <span>{{ $options.i18n.labelText }}</span>
+ <gl-dropdown data-testid="pipeline-view-selector" class="gl-ml-4">
+ <template #button-content>
+ <gl-sprintf :message="currentDropdownText">
+ <template #code="{ content }">
+ <code> {{ content }} </code>
+ </template>
+ </gl-sprintf>
+ <gl-icon class="gl-px-2" name="angle-down" :size="16" />
+ </template>
+ <gl-dropdown-item
+ v-for="view in $options.views"
+ :key="view.type"
+ :secondary-text="view.text.secondary"
+ @click="itemClick(view.type)"
+ >
+ <b>
+ <gl-sprintf :message="view.text.primary">
+ <template #code="{ content }">
+ <code> {{ content }} </code>
+ </template>
+ </gl-sprintf>
+ </b>
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
index f6aee8c5fcf..6451605a222 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue
@@ -1,8 +1,7 @@
<script>
-import { GlTooltipDirective } from '@gitlab/ui';
-import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
+import { JOB_DROPDOWN, SINGLE_JOB } from './constants';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
/**
* Renders the dropdown for the pipeline graph.
@@ -11,12 +10,8 @@ import { reportToSentry } from './utils';
*
*/
export default {
- directives: {
- GlTooltip: GlTooltipDirective,
- },
components: {
JobItem,
- CiIcon,
},
props: {
group: {
@@ -28,6 +23,15 @@ export default {
required: false,
default: -1,
},
+ stageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ jobItemTypes: {
+ jobDropdown: JOB_DROPDOWN,
+ singleJob: SINGLE_JOB,
},
computed: {
computedJobId() {
@@ -51,22 +55,20 @@ export default {
<template>
<div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright">
<button
- v-gl-tooltip.hover="{ boundary: 'viewport' }"
- :title="tooltipText"
type="button"
data-toggle="dropdown"
data-display="static"
- class="dropdown-menu-toggle build-content gl-build-content"
+ class="dropdown-menu-toggle build-content gl-build-content gl-pipeline-job-width! gl-pr-4!"
>
<div class="gl-display-flex gl-align-items-center gl-justify-content-space-between">
- <span class="gl-display-flex gl-align-items-center gl-min-w-0">
- <ci-icon :status="group.status" :size="24" class="gl-line-height-0" />
- <span class="gl-text-truncate mw-70p gl-pl-3">
- {{ group.name }}
- </span>
- </span>
+ <job-item
+ :type="$options.jobItemTypes.jobDropdown"
+ :group-tooltip="tooltipText"
+ :job="group"
+ :stage-name="stageName"
+ />
- <span class="gl-font-weight-100 gl-font-size-lg"> {{ group.size }} </span>
+ <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4">{{ group.size }}</div>
</div>
</button>
@@ -77,6 +79,7 @@ export default {
<job-item
:dropdown-length="group.size"
:job="job"
+ :type="$options.jobItemTypes.singleJob"
css-class-job-name="mini-pipeline-graph-dropdown-item"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue
index 46ef0457d40..6584d89d87c 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_item.vue
+++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue
@@ -3,11 +3,12 @@ import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { sprintf } from '~/locale';
+import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
+import JobNameComponent from '../jobs_shared/job_name_component.vue';
import { accessValue } from './accessors';
-import ActionComponent from './action_component.vue';
-import { REST } from './constants';
-import JobNameComponent from './job_name_component.vue';
-import { reportToSentry } from './utils';
+import { REST, SINGLE_JOB } from './constants';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
@@ -38,6 +39,7 @@ export default {
hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
components: {
ActionComponent,
+ CiIcon,
JobNameComponent,
GlLink,
},
@@ -65,6 +67,11 @@ export default {
required: false,
default: Infinity,
},
+ groupTooltip: {
+ type: String,
+ required: false,
+ default: '',
+ },
jobHovered: {
type: String,
required: false,
@@ -80,24 +87,55 @@ export default {
required: false,
default: -1,
},
+ sourceJobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ stageName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ type: {
+ type: String,
+ required: false,
+ default: SINGLE_JOB,
+ },
},
computed: {
boundary() {
return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
},
+ computedJobId() {
+ return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
+ },
detailsPath() {
return accessValue(this.dataMethod, 'detailsPath', this.status);
},
hasDetails() {
return accessValue(this.dataMethod, 'hasDetails', this.status);
},
- computedJobId() {
- return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : '';
+ isSingleItem() {
+ return this.type === SINGLE_JOB;
+ },
+ nameComponent() {
+ return this.hasDetails ? 'gl-link' : 'div';
+ },
+ showStageName() {
+ return Boolean(this.stageName);
},
status() {
return this.job && this.job.status ? this.job.status : {};
},
+ testId() {
+ return this.hasDetails ? 'job-with-link' : 'job-without-link';
+ },
tooltipText() {
+ if (this.groupTooltip) {
+ return this.groupTooltip;
+ }
+
const textBuilder = [];
const { name: jobName } = this.job;
@@ -129,7 +167,7 @@ export default {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
relatedDownstreamHovered() {
- return this.job.name === this.jobHovered;
+ return this.job.name === this.sourceJobHovered;
},
relatedDownstreamExpanded() {
return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
@@ -147,6 +185,17 @@ export default {
hideTooltips() {
this.$root.$emit(BV_HIDE_TOOLTIP);
},
+ jobItemClick(evt) {
+ if (this.isSingleItem) {
+ /*
+ This is so the jobDropdown still toggles. Issue to refactor:
+ https://gitlab.com/gitlab-org/gitlab/-/issues/267117
+ */
+ evt.stopPropagation();
+ }
+
+ this.hideTooltips();
+ },
pipelineActionRequestComplete() {
this.$emit('pipelineActionRequestComplete');
},
@@ -156,40 +205,45 @@ export default {
<template>
<div
:id="computedJobId"
- class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between gl-w-full"
data-qa-selector="job_item_container"
>
- <gl-link
- v-if="hasDetails"
- v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
- :href="detailsPath"
- :title="tooltipText"
- :class="jobClasses"
- class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
- data-testid="job-with-link"
- @click.stop="hideTooltips"
- @mouseout="hideTooltips"
- >
- <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
- </gl-link>
-
- <div
- v-else
- v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
+ <component
+ :is="nameComponent"
+ v-gl-tooltip="{
+ boundary: 'viewport',
+ placement: 'bottom',
+ customClass: 'gl-pointer-events-none',
+ }"
:title="tooltipText"
:class="jobClasses"
- class="js-job-component-tooltip non-details-job-component menu-item"
- data-testid="job-without-link"
+ :href="detailsPath"
+ class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full"
+ :data-testid="testId"
+ @click="jobItemClick"
@mouseout="hideTooltips"
>
- <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
- </div>
+ <div class="ci-job-name-component gl-display-flex gl-align-items-center">
+ <ci-icon :size="24" :status="job.status" class="gl-line-height-0" />
+ <div class="gl-pl-3 gl-display-flex gl-flex-direction-column gl-w-full">
+ <div class="gl-text-truncate mw-70p gl-line-height-normal">{{ job.name }}</div>
+ <div
+ v-if="showStageName"
+ data-testid="stage-name-in-job"
+ class="gl-text-truncate mw-70p gl-font-sm gl-text-gray-500 gl-line-height-normal"
+ >
+ {{ stageName }}
+ </div>
+ </div>
+ </div>
+ </component>
<action-component
v-if="hasAction"
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
+ class="gl-mr-1"
data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
index add7b3445f7..3f746731e34 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue
@@ -3,9 +3,9 @@ import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@g
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { __, sprintf } from '~/locale';
import CiStatus from '~/vue_shared/components/ci_icon.vue';
+import { reportToSentry } from '../../utils';
import { accessValue } from './accessors';
import { DOWNSTREAM, REST, UPSTREAM } from './constants';
-import { reportToSentry } from './utils';
export default {
directives: {
@@ -183,6 +183,7 @@ export default {
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon"
+ :aria-label="__('Expand pipeline')"
data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline"
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
index b55a77a3c4f..7f772e35e55 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue
@@ -1,11 +1,12 @@
<script>
import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql';
import { LOAD_FAILURE } from '../../constants';
-import { ONE_COL_WIDTH, UPSTREAM } from './constants';
+import { reportToSentry } from '../../utils';
+import { listByLayers } from '../parsing_utils';
+import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
import {
getQueryHeaders,
- reportToSentry,
serializeLoadErrors,
toggleQueryPollingByVisibility,
unwrapPipelineData,
@@ -35,11 +36,16 @@ export default {
type: String,
required: true,
},
+ viewType: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
currentPipeline: null,
loadingPipelineId: null,
+ pipelineLayers: {},
pipelineExpanded: false,
};
},
@@ -123,6 +129,13 @@ export default {
toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline);
},
+ getPipelineLayers(id) {
+ if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) {
+ this.pipelineLayers[id] = listByLayers(this.currentPipeline);
+ }
+
+ return this.pipelineLayers[id];
+ },
isExpanded(id) {
return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id);
},
@@ -203,7 +216,9 @@ export default {
class="d-inline-block gl-mt-n2"
:config-paths="configPaths"
:pipeline="currentPipeline"
+ :pipeline-layers="getPipelineLayers(pipeline.id)"
:is-linked-pipeline="true"
+ :view-type="viewType"
/>
</div>
</li>
diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
index 0d1ff94c275..39baeb6e1c3 100644
--- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue
@@ -1,7 +1,7 @@
<script>
+import { reportToSentry } from '../../utils';
import { UPSTREAM } from './constants';
import LinkedPipeline from './linked_pipeline.vue';
-import { reportToSentry } from './utils';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
index 0a762563114..fa2f381c8a4 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
@@ -1,12 +1,13 @@
<script>
import { capitalize, escape, isEmpty } from 'lodash';
+import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { reportToSentry } from '../../utils';
import MainGraphWrapper from '../graph_shared/main_graph_wrapper.vue';
+import ActionComponent from '../jobs_shared/action_component.vue';
import { accessValue } from './accessors';
-import ActionComponent from './action_component.vue';
import { GRAPHQL } from './constants';
import JobGroupDropdown from './job_group_dropdown.vue';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
export default {
components: {
@@ -15,17 +16,18 @@ export default {
JobItem,
MainGraphWrapper,
},
+ mixins: [glFeatureFlagMixin()],
props: {
groups: {
type: Array,
required: true,
},
- pipelineId: {
- type: Number,
+ name: {
+ type: String,
required: true,
},
- title: {
- type: String,
+ pipelineId: {
+ type: Number,
required: true,
},
action: {
@@ -48,6 +50,16 @@ export default {
required: false,
default: () => ({}),
},
+ showStageName: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ sourceJobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
titleClasses: [
'gl-font-weight-bold',
@@ -57,8 +69,23 @@ export default {
'gl-pl-3',
],
computed: {
+ /*
+ currentGroups and filteredGroups are part of
+ a test to hunt down a bug
+ (see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57142).
+
+ They should be removed when the bug is rectified.
+ */
+ currentGroups() {
+ return this.glFeatures.pipelineFilterJobs ? this.filteredGroups : this.groups;
+ },
+ filteredGroups() {
+ return this.groups.map((group) => {
+ return { ...group, jobs: group.jobs.filter(Boolean) };
+ });
+ },
formattedTitle() {
- return capitalize(escape(this.title));
+ return capitalize(escape(this.name));
},
hasAction() {
return !isEmpty(this.action);
@@ -80,6 +107,18 @@ export default {
isFadedOut(jobName) {
return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName);
},
+ isParallel(group) {
+ return group.size > 1 && group.jobs.length > 1;
+ },
+ singleJobExists(group) {
+ const firstJobDefined = Boolean(group.jobs?.[0]);
+
+ if (!firstJobDefined) {
+ reportToSentry('stage_column_component', 'undefined_job_hunt');
+ }
+
+ return group.size === 1 && firstJobDefined;
+ },
},
};
</script>
@@ -104,7 +143,7 @@ export default {
</template>
<template #jobs>
<div
- v-for="group in groups"
+ v-for="group in currentGroups"
:id="groupId(group)"
:key="getGroupId(group)"
data-testid="stage-column-group"
@@ -113,17 +152,23 @@ export default {
@mouseleave="$emit('jobHover', '')"
>
<job-item
- v-if="group.size === 1"
+ v-if="singleJobExists(group)"
:job="group.jobs[0]"
:job-hovered="jobHovered"
+ :source-job-hovered="sourceJobHovered"
:pipeline-expanded="pipelineExpanded"
:pipeline-id="pipelineId"
+ :stage-name="showStageName ? group.stageName : ''"
css-class-job-name="gl-build-content"
:class="{ 'gl-opacity-3': isFadedOut(group.name) }"
@pipelineActionRequestComplete="$emit('refreshPipelineGraph')"
/>
- <div v-else :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
- <job-group-dropdown :group="group" :pipeline-id="pipelineId" />
+ <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }">
+ <job-group-dropdown
+ :group="group"
+ :stage-name="showStageName ? group.stageName : ''"
+ :pipeline-id="pipelineId"
+ />
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
index 2cee2fbbd8f..cbaf07c05cf 100644
--- a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
+++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue
@@ -1,10 +1,10 @@
<script>
import { isEmpty, escape } from 'lodash';
import stageColumnMixin from '../../mixins/stage_column_mixin';
-import ActionComponent from './action_component.vue';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
import JobGroupDropdown from './job_group_dropdown.vue';
import JobItem from './job_item.vue';
-import { reportToSentry } from './utils';
export default {
components: {
diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js
index b9a8e2638bc..373aa6bf9a1 100644
--- a/app/assets/javascripts/pipelines/components/graph/utils.js
+++ b/app/assets/javascripts/pipelines/components/graph/utils.js
@@ -1,7 +1,6 @@
-import * as Sentry from '@sentry/browser';
import Visibility from 'visibilityjs';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { unwrapStagesWithNeeds } from '../unwrapping_utils';
+import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils';
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
return {
@@ -24,13 +23,6 @@ const getQueryHeaders = (etagResource) => {
};
};
-const reportToSentry = (component, failureType) => {
- Sentry.withScope((scope) => {
- scope.setTag('component', component);
- Sentry.captureException(failureType);
- });
-};
-
const serializeGqlErr = (gqlError) => {
const { locations = [], message = '', path = [] } = gqlError;
@@ -94,12 +86,13 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => {
stages: { nodes: stages },
} = pipeline;
- const nodes = unwrapStagesWithNeeds(stages);
+ const { stages: updatedStages, lookup } = unwrapStagesWithNeedsAndLookup(stages);
return {
...pipeline,
id: getIdFromGraphQLId(pipeline.id),
- stages: nodes,
+ stages: updatedStages,
+ stagesLookup: lookup,
upstream: upstream
? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
: [],
@@ -113,7 +106,6 @@ const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0;
export {
getQueryHeaders,
- reportToSentry,
serializeGqlErr,
serializeLoadErrors,
toggleQueryPollingByVisibility,
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/api.js b/app/assets/javascripts/pipelines/components/graph_shared/api.js
index 04ac15ae24c..49cd04d11e9 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/api.js
+++ b/app/assets/javascripts/pipelines/components/graph_shared/api.js
@@ -1,5 +1,5 @@
import axios from '~/lib/utils/axios_utils';
-import { reportToSentry } from '../graph/utils';
+import { reportToSentry } from '../../utils';
export const reportPerformance = (path, stats) => {
axios.post(path, stats).catch((err) => {
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
index fad57084992..0ed5b8a5f09 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue
@@ -10,8 +10,8 @@ import {
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { DRAW_FAILURE } from '../../constants';
-import { createJobsHash, generateJobNeedsDict } from '../../utils';
-import { reportToSentry } from '../graph/utils';
+import { createJobsHash, generateJobNeedsDict, reportToSentry } from '../../utils';
+import { STAGE_VIEW } from '../graph/constants';
import { parseData } from '../parsing_utils';
import { reportPerformance } from './api';
import { generateLinksData } from './drawing_utils';
@@ -55,11 +55,17 @@ export default {
required: false,
default: '',
},
+ viewType: {
+ type: String,
+ required: false,
+ default: STAGE_VIEW,
+ },
},
data() {
return {
links: [],
needsObject: null,
+ parsedData: {},
};
},
computed: {
@@ -109,6 +115,15 @@ export default {
highlightedJobs(jobs) {
this.$emit('highlightedJobsChange', jobs);
},
+ viewType() {
+ /*
+ We need to wait a tick so that the layout reflows
+ before the links refresh.
+ */
+ this.$nextTick(() => {
+ this.refreshLinks();
+ });
+ },
},
errorCaptured(err, _vm, info) {
reportToSentry(this.$options.name, `error: ${err}, info: ${info}`);
@@ -167,14 +182,17 @@ export default {
this.beginPerfMeasure();
try {
const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups);
- const parsedData = parseData(arrayOfJobs);
- this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`);
+ this.parsedData = parseData(arrayOfJobs);
+ this.refreshLinks();
} catch (err) {
this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false });
reportToSentry(this.$options.name, err);
}
this.finishPerfMeasureAndSend();
},
+ refreshLinks() {
+ this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`);
+ },
getLinkClasses(link) {
return [
this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor,
diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
index 42eab13b0bd..8dbab245f44 100644
--- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
+++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue
@@ -11,7 +11,7 @@ import {
PIPELINES_DETAIL_LINKS_JOB_RATIO,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
-import { reportToSentry } from '../graph/utils';
+import { reportToSentry } from '../../utils';
import { parseData } from '../parsing_utils';
import { reportPerformance } from './api';
import LinksInner from './links_inner.vue';
diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue
index 4ce43b92c93..d8e7b83a8c1 100644
--- a/app/assets/javascripts/pipelines/components/header_component.vue
+++ b/app/assets/javascripts/pipelines/components/header_component.vue
@@ -8,6 +8,7 @@ import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutatio
import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql';
import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql';
import getPipelineQuery from '../graphql/queries/get_pipeline_header_data.query.graphql';
+import { getQueryHeaders } from './graph/utils';
const DELETE_MODAL_ID = 'pipeline-delete-modal';
const POLL_INTERVAL = 10000;
@@ -34,7 +35,9 @@ export default {
[DEFAULT]: __('An unknown error occurred.'),
},
inject: {
- // Receive `fullProject` and `pipelinesPath`
+ graphqlResourceEtag: {
+ default: '',
+ },
paths: {
default: {},
},
@@ -47,6 +50,9 @@ export default {
},
apollo: {
pipeline: {
+ context() {
+ return getQueryHeaders(this.graphqlResourceEtag);
+ },
query: getPipelineQuery,
variables() {
return {
diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
index 1df693704d4..3972c126673 100644
--- a/app/assets/javascripts/pipelines/components/graph/action_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/action_component.vue
@@ -5,7 +5,7 @@ import axios from '~/lib/utils/axios_utils';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { dasherize } from '~/lib/utils/text_utility';
import { __ } from '~/locale';
-import { reportToSentry } from './utils';
+import { reportToSentry } from '../../utils';
/**
* Renders either a cancel, retry or play icon button and handles the post request
diff --git a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
index fffd8e1818a..fffd8e1818a 100644
--- a/app/assets/javascripts/pipelines/components/graph/job_name_component.vue
+++ b/app/assets/javascripts/pipelines/components/jobs_shared/job_name_component.vue
diff --git a/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
new file mode 100644
index 00000000000..6982586ab12
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/notification/pipeline_notification.vue
@@ -0,0 +1,90 @@
+<script>
+import { GlBanner, GlLink, GlSprintf } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { __ } from '~/locale';
+import DismissPipelineNotification from '../../graphql/mutations/dismiss_pipeline_notification.graphql';
+import getUserCallouts from '../../graphql/queries/get_user_callouts.query.graphql';
+
+const featureName = 'pipeline_needs_banner';
+const enumFeatureName = featureName.toUpperCase();
+
+export default {
+ i18n: {
+ title: __('View job dependencies in the pipeline graph!'),
+ description: __(
+ 'You can now group jobs in the pipeline graph based on which jobs are configured to run first, if you use the %{codeStart}needs:%{codeEnd} keyword to establish job dependencies in your CI/CD pipelines. %{linkStart}Learn how to speed up your pipeline with needs.%{linkEnd}',
+ ),
+ buttonText: __('Provide feedback'),
+ },
+ components: {
+ GlBanner,
+ GlLink,
+ GlSprintf,
+ },
+ apollo: {
+ callouts: {
+ query: getUserCallouts,
+ update(data) {
+ return data?.currentUser?.callouts?.nodes.map((c) => c.featureName);
+ },
+ error() {
+ this.hasError = true;
+ },
+ },
+ },
+ inject: ['dagDocPath'],
+ data() {
+ return {
+ callouts: [],
+ dismissedAlert: false,
+ hasError: false,
+ };
+ },
+ computed: {
+ showBanner() {
+ return (
+ !this.$apollo.queries.callouts?.loading &&
+ !this.hasError &&
+ !this.dismissedAlert &&
+ !this.callouts.includes(enumFeatureName)
+ );
+ },
+ },
+ methods: {
+ handleClose() {
+ this.dismissedAlert = true;
+ try {
+ this.$apollo.mutate({
+ mutation: DismissPipelineNotification,
+ variables: {
+ featureName,
+ },
+ });
+ } catch {
+ createFlash(__('There was a problem dismissing this notification.'));
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-banner
+ v-if="showBanner"
+ :title="$options.i18n.title"
+ :button-text="$options.i18n.buttonText"
+ button-link="https://gitlab.com/gitlab-org/gitlab/-/issues/327688"
+ variant="introduction"
+ @close="handleClose"
+ >
+ <p>
+ <gl-sprintf :message="$options.i18n.description">
+ <template #link="{ content }">
+ <gl-link :href="dagDocPath" target="_blank"> {{ content }}</gl-link>
+ </template>
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-banner>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js
index 9c97fa832d0..f5ab869633b 100644
--- a/app/assets/javascripts/pipelines/components/parsing_utils.js
+++ b/app/assets/javascripts/pipelines/components/parsing_utils.js
@@ -1,4 +1,5 @@
import { uniqWith, isEqual } from 'lodash';
+import { createSankey } from './dag/drawing_utils';
/*
The following functions are the main engine in transforming the data as
@@ -144,3 +145,28 @@ export const getMaxNodes = (nodes) => {
export const removeOrphanNodes = (sankeyfiedNodes) => {
return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length);
};
+
+/*
+ This utility accepts unwrapped pipeline data in the format returned from
+ our standard pipeline GraphQL query and returns a list of names by layer
+ for the layer view. It can be combined with the stageLookup on the pipeline
+ to generate columns by layer.
+*/
+
+export const listByLayers = ({ stages }) => {
+ const arrayOfJobs = stages.flatMap(({ groups }) => groups);
+ const parsedData = parseData(arrayOfJobs);
+ const dataWithLayers = createSankey()(parsedData);
+
+ return dataWithLayers.nodes.reduce((acc, { layer, name }) => {
+ /* sort groups by layer */
+
+ if (!acc[layer]) {
+ acc[layer] = [];
+ }
+
+ acc[layer].push(name);
+
+ return acc;
+ }, []);
+};
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
index 51a95612d3f..01baf0a42d5 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/job_pill.vue
@@ -10,6 +10,10 @@ export default {
type: String,
required: true,
},
+ pipelineId: {
+ type: Number,
+ required: true,
+ },
isHighlighted: {
type: Boolean,
required: false,
@@ -32,6 +36,9 @@ export default {
},
},
computed: {
+ id() {
+ return `${this.jobName}-${this.pipelineId}`;
+ },
jobPillClasses() {
return [
{ 'gl-opacity-3': this.isFadedOut },
@@ -52,7 +59,7 @@ export default {
<template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div
- :id="jobName"
+ :id="id"
class="gl-w-15 gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease"
:class="jobPillClasses"
@mouseover="onMouseEnter"
diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
index 707d6966e77..3ba0d7d0120 100644
--- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue
@@ -1,11 +1,8 @@
<script>
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
-import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants';
-import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants';
-import { createJobsHash, generateJobNeedsDict } from '../../utils';
-import { generateLinksData } from '../graph_shared/drawing_utils';
-import { parseData } from '../parsing_utils';
+import { DRAW_FAILURE, DEFAULT } from '../../constants';
+import LinksLayer from '../graph_shared/links_layer.vue';
import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue';
@@ -13,18 +10,16 @@ export default {
components: {
GlAlert,
JobPill,
+ LinksLayer,
StagePill,
},
CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
- CONTAINER_ID: 'pipeline-graph-container',
+ BASE_CONTAINER_ID: 'pipeline-graph-container',
+ PIPELINE_ID: 0,
STROKE_WIDTH: 2,
errorTexts: {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
- [EMPTY_PIPELINE_DATA]: __(
- 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.',
- ),
- [INVALID_CI_CONFIG]: __('Your CI configuration file is invalid.'),
},
props: {
pipelineData: {
@@ -36,33 +31,16 @@ export default {
return {
failureType: null,
highlightedJob: null,
- links: [],
- needsObject: null,
- height: 0,
- width: 0,
+ highlightedJobs: [],
+ measurements: {
+ height: 0,
+ width: 0,
+ },
};
},
computed: {
- hideGraph() {
- // We won't even try to render the graph with these condition
- // because it would cause additional errors down the line for the user
- // which is confusing.
- return this.isPipelineDataEmpty || this.isInvalidCiConfig;
- },
- pipelineStages() {
- return this.pipelineData?.stages || [];
- },
- isPipelineDataEmpty() {
- return !this.isInvalidCiConfig && this.pipelineStages.length === 0;
- },
- isInvalidCiConfig() {
- return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID;
- },
- hasError() {
- return this.failureType;
- },
- hasHighlightedJob() {
- return Boolean(this.highlightedJob);
+ containerId() {
+ return `${this.$options.BASE_CONTAINER_ID}-${this.$options.PIPELINE_ID}`;
},
failure() {
switch (this.failureType) {
@@ -72,18 +50,6 @@ export default {
variant: 'danger',
dismissible: true,
};
- case EMPTY_PIPELINE_DATA:
- return {
- text: this.$options.errorTexts[EMPTY_PIPELINE_DATA],
- variant: 'tip',
- dismissible: false,
- };
- case INVALID_CI_CONFIG:
- return {
- text: this.$options.errorTexts[INVALID_CI_CONFIG],
- variant: 'danger',
- dismissible: false,
- };
default:
return {
text: this.$options.errorTexts[DEFAULT],
@@ -92,56 +58,32 @@ export default {
};
}
},
- viewBox() {
- return [0, 0, this.width, this.height];
+ hasError() {
+ return this.failureType;
},
- highlightedJobs() {
- // If you are hovering on a job, then the jobs we want to highlight are:
- // The job you are currently hovering + all of its needs.
- return [this.highlightedJob, ...this.needsObject[this.highlightedJob]];
+ hasHighlightedJob() {
+ return Boolean(this.highlightedJob);
},
- highlightedLinks() {
- // If you are hovering on a job, then the links we want to highlight are:
- // All the links whose `source` and `target` are highlighted jobs.
- if (this.hasHighlightedJob) {
- const filteredLinks = this.links.filter((link) => {
- return (
- this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target)
- );
- });
-
- return filteredLinks.map((link) => link.ref);
- }
-
- return [];
+ pipelineStages() {
+ return this.pipelineData?.stages || [];
},
},
watch: {
pipelineData: {
immediate: true,
handler() {
- if (this.isPipelineDataEmpty) {
- this.reportFailure(EMPTY_PIPELINE_DATA);
- } else if (this.isInvalidCiConfig) {
- this.reportFailure(INVALID_CI_CONFIG);
- } else {
- this.$nextTick(() => {
- this.computeGraphDimensions();
- this.prepareLinkData();
- });
- }
+ this.$nextTick(() => {
+ this.computeGraphDimensions();
+ });
},
},
},
methods: {
- prepareLinkData() {
- try {
- const arrayOfJobs = this.pipelineStages.flatMap(({ groups }) => groups);
- const parsedData = parseData(arrayOfJobs);
- this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID);
- } catch {
- this.reportFailure(DRAW_FAILURE);
- }
+ computeGraphDimensions() {
+ this.measurements = {
+ width: this.$refs[this.$options.CONTAINER_REF].scrollWidth,
+ height: this.$refs[this.$options.CONTAINER_REF].scrollHeight,
+ };
},
getStageBackgroundClasses(index) {
const { length } = this.pipelineStages;
@@ -161,22 +103,14 @@ export default {
return '';
},
- highlightNeeds(uniqueJobId) {
- // The first time we hover, we create the object where
- // we store all the data to properly highlight the needs.
- if (!this.needsObject) {
- const jobs = createJobsHash(this.pipelineStages);
- this.needsObject = generateJobNeedsDict(jobs) ?? {};
- }
-
- this.highlightedJob = uniqueJobId;
+ isJobHighlighted(jobName) {
+ return this.highlightedJobs.includes(jobName);
},
- removeHighlightNeeds() {
- this.highlightedJob = null;
+ onError(error) {
+ this.reportFailure(error.type);
},
- computeGraphDimensions() {
- this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`;
- this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`;
+ removeHoveredJob() {
+ this.highlightedJob = null;
},
reportFailure(errorType) {
this.failureType = errorType;
@@ -184,17 +118,11 @@ export default {
resetFailure() {
this.failureType = null;
},
- isJobHighlighted(jobName) {
- return this.highlightedJobs.includes(jobName);
+ setHoveredJob(jobName) {
+ this.highlightedJob = jobName;
},
- isLinkHighlighted(linkRef) {
- return this.highlightedLinks.includes(linkRef);
- },
- getLinkClasses(link) {
- return [
- this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : 'gl-stroke-gray-200',
- { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) },
- ];
+ updateHighlightedJobs(jobs) {
+ this.highlightedJobs = jobs;
},
},
};
@@ -209,50 +137,44 @@ export default {
>
{{ failure.text }}
</gl-alert>
- <div
- v-if="!hideGraph"
- :id="$options.CONTAINER_ID"
- :ref="$options.CONTAINER_REF"
- class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
- data-testid="graph-container"
- >
- <svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
- <path
- v-for="link in links"
- :key="link.path"
- :ref="link.ref"
- :d="link.path"
- class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease"
- :class="getLinkClasses(link)"
- :stroke-width="$options.STROKE_WIDTH"
- />
- </svg>
- <div
- v-for="(stage, index) in pipelineStages"
- :key="`${stage.name}-${index}`"
- class="gl-flex-direction-column"
+ <div :id="containerId" :ref="$options.CONTAINER_REF" data-testid="graph-container">
+ <links-layer
+ :pipeline-data="pipelineStages"
+ :pipeline-id="$options.PIPELINE_ID"
+ :container-id="containerId"
+ :container-measurements="measurements"
+ :highlighted-job="highlightedJob"
+ @highlightedJobsChange="updateHighlightedJobs"
+ @error="onError"
>
<div
- class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
- :class="getStageBackgroundClasses(index)"
- data-testid="stage-background"
- >
- <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
- </div>
- <div
- class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ v-for="(stage, index) in pipelineStages"
+ :key="`${stage.name}-${index}`"
+ class="gl-flex-direction-column"
>
- <job-pill
- v-for="group in stage.groups"
- :key="group.name"
- :job-name="group.name"
- :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
- :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
- @on-mouse-enter="highlightNeeds"
- @on-mouse-leave="removeHighlightNeeds"
- />
+ <div
+ class="gl-display-flex gl-align-items-center gl-bg-white gl-w-full gl-px-8 gl-py-4 gl-mb-5"
+ :class="getStageBackgroundClasses(index)"
+ data-testid="stage-background"
+ >
+ <stage-pill :stage-name="stage.name" :is-empty="stage.groups.length === 0" />
+ </div>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
+ >
+ <job-pill
+ v-for="group in stage.groups"
+ :key="group.name"
+ :job-name="group.name"
+ :pipeline-id="$options.PIPELINE_ID"
+ :is-highlighted="hasHighlightedJob && isJobHighlighted(group.name)"
+ :is-faded-out="hasHighlightedJob && !isJobHighlighted(group.name)"
+ @on-mouse-enter="setHoveredJob"
+ @on-mouse-leave="removeHoveredJob"
+ />
+ </div>
</div>
- </div>
+ </links-layer>
</div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
deleted file mode 100644
index 6c3a4a27606..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/blank_state.vue
+++ /dev/null
@@ -1,30 +0,0 @@
-<script>
-export default {
- name: 'PipelinesSvgState',
- props: {
- svgPath: {
- type: String,
- required: true,
- },
-
- message: {
- type: String,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="row empty-state">
- <div class="col-12">
- <div class="svg-content"><img :src="svgPath" /></div>
- </div>
-
- <div class="col-12 text-center">
- <div class="text-content">
- <h4>{{ message }}</h4>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
index f8107d288d9..c3bcfcb18fb 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue
@@ -1,7 +1,9 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
+import Experiment from '~/experimentation/components/experiment.vue';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
+import PipelinesCiTemplates from './pipelines_ci_templates.vue';
export default {
i18n: {
@@ -15,6 +17,8 @@ export default {
name: 'PipelinesEmptyState',
components: {
GlEmptyState,
+ Experiment,
+ PipelinesCiTemplates,
},
props: {
emptyStateSvgPath: {
@@ -35,19 +39,26 @@ export default {
</script>
<template>
<div>
- <gl-empty-state
- v-if="canSetCi"
- :title="$options.i18n.title"
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.description"
- :primary-button-text="$options.i18n.btnText"
- :primary-button-link="ciHelpPagePath"
- />
- <gl-empty-state
- v-else
- title=""
- :svg-path="emptyStateSvgPath"
- :description="$options.i18n.noCiDescription"
- />
+ <experiment name="pipeline_empty_state_templates">
+ <template #control>
+ <gl-empty-state
+ v-if="canSetCi"
+ :title="$options.i18n.title"
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.description"
+ :primary-button-text="$options.i18n.btnText"
+ :primary-button-link="ciHelpPagePath"
+ />
+ <gl-empty-state
+ v-else
+ title=""
+ :svg-path="emptyStateSvgPath"
+ :description="$options.i18n.noCiDescription"
+ />
+ </template>
+ <template #candidate>
+ <pipelines-ci-templates />
+ </template>
+ </experiment>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
new file mode 100644
index 00000000000..670fa398536
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/job_item.vue
@@ -0,0 +1,190 @@
+<script>
+import { GlTooltipDirective, GlLink } from '@gitlab/ui';
+import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin';
+import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
+import { sprintf } from '~/locale';
+import { reportToSentry } from '../../utils';
+import ActionComponent from '../jobs_shared/action_component.vue';
+import JobNameComponent from '../jobs_shared/job_name_component.vue';
+
+/**
+ * Renders the badge for the pipeline graph and the job's dropdown.
+ *
+ * The following object should be provided as `job`:
+ *
+ * {
+ * "id": 4256,
+ * "name": "test",
+ * "status": {
+ * "icon": "status_success",
+ * "text": "passed",
+ * "label": "passed",
+ * "group": "success",
+ * "tooltip": "passed",
+ * "details_path": "/root/ci-mock/builds/4256",
+ * "action": {
+ * "icon": "retry",
+ * "title": "Retry",
+ * "path": "/root/ci-mock/builds/4256/retry",
+ * "method": "post"
+ * }
+ * }
+ * }
+ */
+
+export default {
+ hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500',
+ components: {
+ ActionComponent,
+ JobNameComponent,
+ GlLink,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [delayedJobMixin],
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ cssClassJobName: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ dropdownLength: {
+ type: Number,
+ required: false,
+ default: Infinity,
+ },
+ jobHovered: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ pipelineExpanded: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ pipelineId: {
+ type: Number,
+ required: false,
+ default: -1,
+ },
+ },
+ computed: {
+ boundary() {
+ return this.dropdownLength === 1 ? 'viewport' : 'scrollParent';
+ },
+ detailsPath() {
+ return this.status.details_path;
+ },
+ hasDetails() {
+ return this.status.has_details;
+ },
+ status() {
+ return this.job && this.job.status ? this.job.status : {};
+ },
+ tooltipText() {
+ const textBuilder = [];
+ const { name: jobName } = this.job;
+
+ if (jobName) {
+ textBuilder.push(jobName);
+ }
+
+ const { tooltip: statusTooltip } = this.status;
+ if (jobName && statusTooltip) {
+ textBuilder.push('-');
+ }
+
+ if (statusTooltip) {
+ if (this.isDelayedJob) {
+ textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime }));
+ } else {
+ textBuilder.push(statusTooltip);
+ }
+ }
+
+ return textBuilder.join(' ');
+ },
+ /**
+ * Verifies if the provided job has an action path
+ *
+ * @return {Boolean}
+ */
+ hasAction() {
+ return this.job.status && this.job.status.action && this.job.status.action.path;
+ },
+ relatedDownstreamHovered() {
+ return this.job.name === this.jobHovered;
+ },
+ relatedDownstreamExpanded() {
+ return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded;
+ },
+ jobClasses() {
+ return this.relatedDownstreamHovered || this.relatedDownstreamExpanded
+ ? `${this.$options.hoverClass} ${this.cssClassJobName}`
+ : this.cssClassJobName;
+ },
+ },
+ errorCaptured(err, _vm, info) {
+ reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`);
+ },
+ methods: {
+ hideTooltips() {
+ this.$root.$emit(BV_HIDE_TOOLTIP);
+ },
+ pipelineActionRequestComplete() {
+ this.$emit('pipelineActionRequestComplete');
+ },
+ },
+};
+</script>
+<template>
+ <div
+ class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between"
+ data-qa-selector="job_item_container"
+ >
+ <gl-link
+ v-if="hasDetails"
+ v-gl-tooltip="{
+ boundary: 'viewport',
+ placement: 'bottom',
+ customClass: 'gl-pointer-events-none',
+ }"
+ :href="detailsPath"
+ :title="tooltipText"
+ :class="jobClasses"
+ class="js-pipeline-graph-job-link qa-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none"
+ data-testid="job-with-link"
+ @click.stop="hideTooltips"
+ @mouseout="hideTooltips"
+ >
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
+ </gl-link>
+
+ <div
+ v-else
+ v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }"
+ :title="tooltipText"
+ :class="jobClasses"
+ class="js-job-component-tooltip non-details-job-component menu-item"
+ data-testid="job-without-link"
+ @mouseout="hideTooltips"
+ >
+ <job-name-component :name="job.name" :status="job.status" :icon-size="24" />
+ </div>
+
+ <action-component
+ v-if="hasAction"
+ :tooltip-text="status.action.title"
+ :link="status.action.path"
+ :action-icon="status.action.icon"
+ data-qa-selector="action_button"
+ @pipelineActionRequestComplete="pipelineActionRequestComplete"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
index cf0849751df..235126fea0c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/nav_controls.vue
@@ -41,29 +41,29 @@ export default {
<template>
<div class="nav-controls">
<gl-button
- v-if="newPipelinePath"
- :href="newPipelinePath"
- variant="success"
- category="primary"
- class="js-run-pipeline"
- data-testid="run-pipeline-button"
- data-qa-selector="run_pipeline_button"
- >
- {{ s__('Pipelines|Run Pipeline') }}
- </gl-button>
-
- <gl-button
v-if="resetCachePath"
:loading="isResetCacheButtonLoading"
class="js-clear-cache"
data-testid="clear-cache-button"
@click="onClickResetCache"
>
- {{ s__('Pipelines|Clear Runner Caches') }}
+ {{ s__('Pipelines|Clear runner caches') }}
</gl-button>
<gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button">
- {{ s__('Pipelines|CI Lint') }}
+ {{ s__('Pipelines|CI lint') }}
+ </gl-button>
+
+ <gl-button
+ v-if="newPipelinePath"
+ :href="newPipelinePath"
+ variant="confirm"
+ category="primary"
+ class="js-run-pipeline"
+ data-testid="run-pipeline-button"
+ data-qa-selector="run_pipeline_button"
+ >
+ {{ s__('Pipeline|Run pipeline') }}
</gl-button>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
index 05372010d0f..2b33467e948 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue
@@ -36,7 +36,7 @@ export default {
};
</script>
<template>
- <div data-testid="widget-mini-pipeline-graph">
+ <div data-testid="pipeline-mini-graph">
<div
v-for="stage in stages"
:key="stage.name"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
index bdb7dd06620..bf992b84387 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue
@@ -17,7 +17,7 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import eventHub from '../../event_hub';
-import JobItem from '../graph/job_item.vue';
+import JobItem from './job_item.vue';
export default {
components: {
@@ -103,7 +103,7 @@ export default {
<template>
<gl-dropdown
ref="dropdown"
- v-gl-tooltip.hover
+ v-gl-tooltip.hover.ds0
data-testid="mini-pipeline-graph-dropdown"
:title="stage.title"
variant="link"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
index c707b395192..0528e4c147c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_triggerer.vue
@@ -17,19 +17,11 @@ export default {
user() {
return this.pipeline.user;
},
- classes() {
- const triggererClass = 'pipeline-triggerer';
-
- if (this.glFeatures.newPipelinesTable) {
- return triggererClass;
- }
- return `table-section section-10 d-none d-md-block ${triggererClass}`;
- },
},
};
</script>
<template>
- <div :class="classes" data-testid="pipeline-triggerer">
+ <div class="pipeline-triggerer" data-testid="pipeline-triggerer">
<user-avatar-link
v-if="user"
:link-href="user.path"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
index 0de520a2ca7..d39e120dc6c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue
@@ -49,19 +49,11 @@ export default {
autoDevopsHelpPath() {
return helpPagePath('topics/autodevops/index.md');
},
- classes() {
- const tagsClass = 'pipeline-tags';
-
- if (this.glFeatures.newPipelinesTable) {
- return tagsClass;
- }
- return `table-section section-10 d-none d-md-block ${tagsClass}`;
- },
},
};
</script>
<template>
- <div :class="classes" data-testid="pipeline-url-table-cell">
+ <div class="pipeline-tags" data-testid="pipeline-url-table-cell">
<gl-link
:href="pipeline.path"
data-testid="pipeline-url-link"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
index 19d93e7d083..f14a582d731 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue
@@ -1,5 +1,5 @@
<script>
-import { GlIcon, GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { isEqual } from 'lodash';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils';
@@ -10,7 +10,6 @@ import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../
import PipelinesMixin from '../../mixins/pipelines_mixin';
import PipelinesService from '../../services/pipelines_service';
import { validateParams } from '../../utils';
-import SvgBlankState from './blank_state.vue';
import EmptyState from './empty_state.vue';
import NavigationControls from './nav_controls.vue';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
@@ -19,13 +18,13 @@ import PipelinesTableComponent from './pipelines_table.vue';
export default {
components: {
EmptyState,
+ GlEmptyState,
GlIcon,
GlLoadingIcon,
NavigationTabs,
NavigationControls,
PipelinesFilteredSearch,
PipelinesTableComponent,
- SvgBlankState,
TablePagination,
},
mixins: [PipelinesMixin],
@@ -314,6 +313,7 @@ export default {
</div>
<pipelines-filtered-search
+ v-if="stateToRender !== $options.stateMap.emptyState"
:project-id="projectId"
:params="validatedParams"
@filterPipelines="filterPipelines"
@@ -333,19 +333,19 @@ export default {
:can-set-ci="canCreatePipeline"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="stateToRender === $options.stateMap.error"
:svg-path="errorStateSvgPath"
- :message="
+ :title="
s__(`Pipelines|There was an error fetching the pipelines.
Try again in a few moments or contact your support team.`)
"
/>
- <svg-blank-state
+ <gl-empty-state
v-else-if="stateToRender === $options.stateMap.emptyTab"
:svg-path="noPipelinesSvgPath"
- :message="emptyTabMessage"
+ :title="emptyTabMessage"
/>
<div v-else-if="stateToRender === $options.stateMap.tableList">
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
new file mode 100644
index 00000000000..c2ec8c57fd7
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_ci_templates.vue
@@ -0,0 +1,143 @@
+<script>
+import { GlButton, GlCard, GlSprintf } from '@gitlab/ui';
+import ExperimentTracking from '~/experimentation/experiment_tracking';
+import { mergeUrlParams } from '~/lib/utils/url_utility';
+import { s__, sprintf } from '~/locale';
+import { HELLO_WORLD_TEMPLATE_KEY } from '../../constants';
+
+export default {
+ components: {
+ GlButton,
+ GlCard,
+ GlSprintf,
+ },
+ HELLO_WORLD_TEMPLATE_KEY,
+ i18n: {
+ cta: s__('Pipelines|Use template'),
+ testTemplates: {
+ title: s__('Pipelines|Use a sample CI/CD template'),
+ subtitle: s__(
+ 'Pipelines|Use a sample %{codeStart}.gitlab-ci.yml%{codeEnd} template file to explore how CI/CD works.',
+ ),
+ helloWorld: {
+ title: s__('Pipelines|“Hello world” with GitLab CI/CD'),
+ description: s__(
+ 'Pipelines|Get familiar with GitLab CI/CD syntax by starting with a simple pipeline that runs a “Hello world” script.',
+ ),
+ },
+ },
+ templates: {
+ title: s__('Pipelines|Use a CI/CD template'),
+ subtitle: s__(
+ "Pipelines|Use a template based on your project's language or framework to get started with GitLab CI/CD.",
+ ),
+ description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'),
+ },
+ },
+ inject: ['addCiYmlPath', 'suggestedCiTemplates'],
+ data() {
+ const templates = this.suggestedCiTemplates.map(({ name, logo }) => {
+ return {
+ name,
+ logo,
+ link: mergeUrlParams({ template: name }, this.addCiYmlPath),
+ description: sprintf(this.$options.i18n.templates.description, { name }),
+ };
+ });
+
+ return {
+ templates,
+ helloWorldTemplateUrl: mergeUrlParams(
+ { template: HELLO_WORLD_TEMPLATE_KEY },
+ this.addCiYmlPath,
+ ),
+ };
+ },
+ methods: {
+ trackEvent(template) {
+ const tracking = new ExperimentTracking('pipeline_empty_state_templates', {
+ label: template,
+ });
+ tracking.event('template_clicked');
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.testTemplates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">
+ <gl-sprintf :message="$options.i18n.testTemplates.subtitle">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+
+ <div class="row gl-mb-8">
+ <div class="col-lg-3">
+ <gl-card>
+ <div class="gl-flex-direction-row">
+ <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div>
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800 gl-mb-2">{{
+ $options.i18n.testTemplates.helloWorld.title
+ }}</strong>
+ </div>
+ <p class="gl-font-sm">{{ $options.i18n.testTemplates.helloWorld.description }}</p>
+ </div>
+
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="helloWorldTemplateUrl"
+ data-testid="test-template-link"
+ @click="trackEvent($options.HELLO_WORLD_TEMPLATE_KEY)"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </gl-card>
+ </div>
+ </div>
+
+ <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.templates.title }}</h2>
+ <p class="gl-text-gray-800 gl-mb-6">{{ $options.i18n.templates.subtitle }}</p>
+
+ <ul class="gl-list-style-none gl-pl-0">
+ <li v-for="template in templates" :key="template.name">
+ <div
+ class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3"
+ >
+ <div class="gl-display-flex gl-flex-direction-row gl-align-items-center">
+ <img
+ width="64"
+ height="64"
+ :src="template.logo"
+ class="gl-mr-6"
+ data-testid="template-logo"
+ />
+ <div class="gl-flex-direction-row">
+ <div class="gl-mb-3">
+ <strong class="gl-text-gray-800" data-testid="template-name">{{
+ template.name
+ }}</strong>
+ </div>
+ <p class="gl-mb-0 gl-font-sm" data-testid="template-description">
+ {{ template.description }}
+ </p>
+ </div>
+ </div>
+ <gl-button
+ category="primary"
+ variant="confirm"
+ :href="template.link"
+ data-testid="template-link"
+ @click="trackEvent(template.name)"
+ >
+ {{ $options.i18n.cta }}
+ </gl-button>
+ </div>
+ </li>
+ </ul>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
index aa27aa7e50d..47fc7023222 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue
@@ -1,7 +1,6 @@
<script>
import { GlTable, GlTooltipDirective } from '@gitlab/ui';
import { s__ } from '~/locale';
-import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import eventHub from '../../event_hub';
import PipelineMiniGraph from './pipeline_mini_graph.vue';
import PipelineOperations from './pipeline_operations.vue';
@@ -10,7 +9,6 @@ import PipelineTriggerer from './pipeline_triggerer.vue';
import PipelineUrl from './pipeline_url.vue';
import PipelinesCommit from './pipelines_commit.vue';
import PipelinesStatusBadge from './pipelines_status_badge.vue';
-import PipelinesTableRowComponent from './pipelines_table_row.vue';
import PipelinesTimeago from './time_ago.vue';
const DEFAULT_TD_CLASS = 'gl-p-5!';
@@ -83,7 +81,6 @@ export default {
PipelineOperations,
PipelinesStatusBadge,
PipelineStopModal,
- PipelinesTableRowComponent,
PipelinesTimeago,
PipelineTriggerer,
PipelineUrl,
@@ -91,7 +88,6 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
- mixins: [glFeatureFlagMixin()],
props: {
pipelines: {
type: Array,
@@ -149,41 +145,7 @@ export default {
</script>
<template>
<div class="ci-table">
- <div v-if="!glFeatures.newPipelinesTable" data-testid="legacy-ci-table">
- <div class="gl-responsive-table-row table-row-header" role="row">
- <div class="table-section section-10 js-pipeline-status" role="rowheader">
- {{ s__('Pipeline|Status') }}
- </div>
- <div class="table-section section-10 js-pipeline-info pipeline-info" role="rowheader">
- {{ s__('Pipeline|Pipeline') }}
- </div>
- <div class="table-section section-10 js-triggerer-info triggerer-info" role="rowheader">
- {{ s__('Pipeline|Triggerer') }}
- </div>
- <div class="table-section section-20 js-pipeline-commit pipeline-commit" role="rowheader">
- {{ s__('Pipeline|Commit') }}
- </div>
- <div class="table-section section-15 js-pipeline-stages pipeline-stages" role="rowheader">
- {{ s__('Pipeline|Stages') }}
- </div>
- <div class="table-section section-15" role="rowheader"></div>
- <div class="table-section section-20" role="rowheader">
- <slot name="table-header-actions"></slot>
- </div>
- </div>
- <pipelines-table-row-component
- v-for="model in pipelines"
- :key="model.id"
- :pipeline="model"
- :pipeline-schedule-url="pipelineScheduleUrl"
- :update-graph-dropdown="updateGraphDropdown"
- :view-type="viewType"
- :canceling-pipeline="cancelingPipeline"
- />
- </div>
-
<gl-table
- v-else
:fields="$options.fields"
:items="pipelines"
tbody-tr-class="commit"
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
deleted file mode 100644
index f684a0b0fcd..00000000000
--- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue
+++ /dev/null
@@ -1,269 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
-import CommitComponent from '~/vue_shared/components/commit.vue';
-import eventHub from '../../event_hub';
-import PipelineMiniGraph from './pipeline_mini_graph.vue';
-import PipelineTriggerer from './pipeline_triggerer.vue';
-import PipelineUrl from './pipeline_url.vue';
-import PipelinesArtifactsComponent from './pipelines_artifacts.vue';
-import PipelinesManualActionsComponent from './pipelines_manual_actions.vue';
-import PipelinesTimeago from './time_ago.vue';
-
-export default {
- i18n: {
- cancelTitle: __('Cancel'),
- redeployTitle: __('Retry'),
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- GlModalDirective,
- },
- components: {
- PipelinesManualActionsComponent,
- PipelinesArtifactsComponent,
- CommitComponent,
- PipelineMiniGraph,
- PipelineUrl,
- PipelineTriggerer,
- CiBadge,
- PipelinesTimeago,
- GlButton,
- },
- props: {
- pipeline: {
- type: Object,
- required: true,
- },
- pipelineScheduleUrl: {
- type: String,
- required: false,
- default: '',
- },
- updateGraphDropdown: {
- type: Boolean,
- required: false,
- default: false,
- },
- viewType: {
- type: String,
- required: true,
- },
- cancelingPipeline: {
- type: Number,
- required: false,
- default: null,
- },
- },
- data() {
- return {
- isRetrying: false,
- };
- },
- computed: {
- actions() {
- if (!this.pipeline || !this.pipeline.details) {
- return [];
- }
- const { details } = this.pipeline;
- return [...(details.manual_actions || []), ...(details.scheduled_actions || [])];
- },
- /**
- * If provided, returns the commit tag.
- * Needed to render the commit component column.
- *
- * This field needs a lot of verification, because of different possible cases:
- *
- * 1. person who is an author of a commit might be a GitLab user
- * 2. if person who is an author of a commit is a GitLab user, they can have a GitLab avatar
- * 3. If GitLab user does not have avatar they might have a Gravatar
- * 4. If committer is not a GitLab User they can have a Gravatar
- * 5. We do not have consistent API object in this case
- * 6. We should improve API and the code
- *
- * @returns {Object|Undefined}
- */
- commitAuthor() {
- let commitAuthorInformation;
-
- if (!this.pipeline || !this.pipeline.commit) {
- return null;
- }
-
- // 1. person who is an author of a commit might be a GitLab user
- if (this.pipeline.commit.author) {
- // 2. if person who is an author of a commit is a GitLab user
- // they can have a GitLab avatar
- if (this.pipeline.commit.author.avatar_url) {
- commitAuthorInformation = this.pipeline.commit.author;
-
- // 3. If GitLab user does not have avatar, they might have a Gravatar
- } else if (this.pipeline.commit.author_gravatar_url) {
- commitAuthorInformation = {
- ...this.pipeline.commit.author,
- avatar_url: this.pipeline.commit.author_gravatar_url,
- };
- }
- // 4. If committer is not a GitLab User, they can have a Gravatar
- } else {
- commitAuthorInformation = {
- avatar_url: this.pipeline.commit.author_gravatar_url,
- path: `mailto:${this.pipeline.commit.author_email}`,
- username: this.pipeline.commit.author_name,
- };
- }
-
- return commitAuthorInformation;
- },
- commitTag() {
- return this.pipeline?.ref?.tag;
- },
- commitRef() {
- return this.pipeline?.ref;
- },
- commitUrl() {
- return this.pipeline?.commit?.commit_path;
- },
- commitShortSha() {
- return this.pipeline?.commit?.short_id;
- },
- commitTitle() {
- return this.pipeline?.commit?.title;
- },
- pipelineStatus() {
- return this.pipeline?.details?.status ?? {};
- },
- hasStages() {
- return this.pipeline?.details?.stages?.length > 0;
- },
- displayPipelineActions() {
- return (
- this.pipeline.flags.retryable ||
- this.pipeline.flags.cancelable ||
- this.pipeline.details.manual_actions.length ||
- this.pipeline.details.artifacts.length
- );
- },
- isChildView() {
- return this.viewType === 'child';
- },
- isCancelling() {
- return this.cancelingPipeline === this.pipeline.id;
- },
- },
- watch: {
- pipeline() {
- this.isRetrying = false;
- },
- },
- methods: {
- handleCancelClick() {
- eventHub.$emit('openConfirmationModal', {
- pipeline: this.pipeline,
- endpoint: this.pipeline.cancel_path,
- });
- },
- handleRetryClick() {
- this.isRetrying = true;
- eventHub.$emit('retryPipeline', this.pipeline.retry_path);
- },
- handlePipelineActionRequestComplete() {
- // warn the pipelines table to update
- eventHub.$emit('refreshPipelinesTable');
- },
- },
-};
-</script>
-<template>
- <div class="commit gl-responsive-table-row">
- <div class="table-section section-10 commit-link">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Status') }}</div>
- <div class="table-mobile-content">
- <ci-badge
- :status="pipelineStatus"
- :show-text="!isChildView"
- :icon-classes="'gl-vertical-align-middle!'"
- data-qa-selector="pipeline_commit_status"
- />
- </div>
- </div>
-
- <pipeline-url :pipeline="pipeline" :pipeline-schedule-url="pipelineScheduleUrl" />
- <pipeline-triggerer :pipeline="pipeline" />
-
- <div class="table-section section-wrap section-20">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Commit') }}</div>
- <div class="table-mobile-content">
- <commit-component
- :tag="commitTag"
- :commit-ref="commitRef"
- :commit-url="commitUrl"
- :merge-request-ref="pipeline.merge_request"
- :short-sha="commitShortSha"
- :title="commitTitle"
- :author="commitAuthor"
- :show-ref-info="!isChildView"
- />
- </div>
- </div>
-
- <div class="table-section section-wrap section-15 stage-cell">
- <div class="table-mobile-header" role="rowheader">{{ s__('Pipeline|Stages') }}</div>
- <div class="table-mobile-content">
- <pipeline-mini-graph
- v-if="hasStages"
- :stages="pipeline.details.stages"
- :update-dropdown="updateGraphDropdown"
- @pipelineActionRequestComplete="handlePipelineActionRequestComplete"
- />
- </div>
- </div>
-
- <pipelines-timeago class="gl-text-right" :pipeline="pipeline" />
-
- <div
- v-if="displayPipelineActions"
- class="table-section section-20 table-button-footer pipeline-actions"
- >
- <div class="btn-group table-action-buttons">
- <pipelines-manual-actions-component v-if="actions.length > 0" :actions="actions" />
-
- <pipelines-artifacts-component
- v-if="pipeline.details.artifacts.length"
- :artifacts="pipeline.details.artifacts"
- />
-
- <gl-button
- v-if="pipeline.flags.retryable"
- v-gl-tooltip.hover
- :aria-label="$options.i18n.redeployTitle"
- :title="$options.i18n.redeployTitle"
- :disabled="isRetrying"
- :loading="isRetrying"
- class="js-pipelines-retry-button"
- data-qa-selector="pipeline_retry_button"
- icon="repeat"
- variant="default"
- category="secondary"
- @click="handleRetryClick"
- />
-
- <gl-button
- v-if="pipeline.flags.cancelable"
- v-gl-tooltip.hover
- v-gl-modal-directive="'confirmation-modal'"
- :aria-label="$options.i18n.cancelTitle"
- :title="$options.i18n.cancelTitle"
- :loading="isCancelling"
- :disabled="isCancelling"
- icon="close"
- variant="danger"
- category="primary"
- class="js-pipelines-cancel-button"
- @click="handleCancelClick"
- />
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
index 543bdf94307..e6b03751350 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_list/time_ago.vue
@@ -22,6 +22,12 @@ export default {
finishedTime() {
return this.pipeline?.details?.finished_at;
},
+ skipped() {
+ return this.pipeline?.details?.status?.label === 'skipped';
+ },
+ stuck() {
+ return this.pipeline.flags.stuck;
+ },
durationFormatted() {
const date = new Date(this.duration * 1000);
@@ -42,46 +48,50 @@ export default {
return `${hh}:${mm}:${ss}`;
},
- legacySectionClass() {
- return !this.glFeatures.newPipelinesTable ? 'table-section section-15' : '';
- },
- legacyTableMobileClass() {
- return !this.glFeatures.newPipelinesTable ? 'table-mobile-content' : '';
- },
showInProgress() {
- return !this.duration && !this.finishedTime;
+ return !this.duration && !this.finishedTime && !this.skipped;
+ },
+ showSkipped() {
+ return !this.duration && !this.finishedTime && this.skipped;
},
},
};
</script>
<template>
- <div :class="legacySectionClass">
- <div v-if="!glFeatures.newPipelinesTable" class="table-mobile-header" role="rowheader">
- {{ s__('Pipeline|Duration') }}
- </div>
- <div :class="legacyTableMobileClass">
- <span v-if="showInProgress" data-testid="pipeline-in-progress">
- <gl-icon name="hourglass" class="gl-vertical-align-baseline! gl-mr-2" :size="12" />
- {{ s__('Pipeline|In progress') }}
- </span>
+ <div>
+ <span v-if="showInProgress" data-testid="pipeline-in-progress">
+ <gl-icon v-if="stuck" name="warning" class="gl-mr-2" :size="12" data-testid="warning-icon" />
+ <gl-icon
+ v-else
+ name="hourglass"
+ class="gl-vertical-align-baseline! gl-mr-2"
+ :size="12"
+ data-testid="hourglass-icon"
+ />
+ {{ s__('Pipeline|In progress') }}
+ </span>
+
+ <span v-if="showSkipped" data-testid="pipeline-skipped">
+ <gl-icon name="status_skipped_borderless" class="gl-mr-2" :size="16" />
+ {{ s__('Pipeline|Skipped') }}
+ </span>
- <p v-if="duration" class="duration">
- <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
- {{ durationFormatted }}
- </p>
+ <p v-if="duration" class="duration">
+ <gl-icon name="timer" class="gl-vertical-align-baseline!" :size="12" />
+ {{ durationFormatted }}
+ </p>
- <p v-if="finishedTime" class="finished-at d-none d-md-block">
- <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
+ <p v-if="finishedTime" class="finished-at d-none d-md-block">
+ <gl-icon name="calendar" class="gl-vertical-align-baseline!" :size="12" />
- <time
- v-gl-tooltip
- :title="tooltipTitle(finishedTime)"
- data-placement="top"
- data-container="body"
- >
- {{ timeFormatted(finishedTime) }}
- </time>
- </p>
- </div>
+ <time
+ v-gl-tooltip
+ :title="tooltipTitle(finishedTime)"
+ data-placement="top"
+ data-container="body"
+ >
+ {{ timeFormatted(finishedTime) }}
+ </time>
+ </p>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index d33d4e7dfd0..79b1b6af38b 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -72,6 +72,7 @@ export default {
size="small"
class="gl-mr-3 js-back-button"
icon="angle-left"
+ :aria-label="__('Go back')"
@click="onBackClick"
/>
diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
index 15073079c0a..2d24beb8323 100644
--- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js
+++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js
@@ -1,15 +1,33 @@
+import { reportToSentry } from '../utils';
+
const unwrapGroups = (stages) => {
- return stages.map((stage) => {
+ return stages.map((stage, idx) => {
const {
groups: { nodes: groups },
} = stage;
- return { ...stage, groups };
+
+ /*
+ Being peformance conscious here means we don't want to spread and copy the
+ group value just to add one parameter.
+ */
+ /* eslint-disable no-param-reassign */
+ const groupsWithStageName = groups.map((group) => {
+ group.stageName = stage.name;
+ return group;
+ });
+ /* eslint-enable no-param-reassign */
+
+ return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } };
});
};
const unwrapNodesWithName = (jobArray, prop, field = 'name') => {
+ if (jobArray.length < 1) {
+ reportToSentry('unwrapping_utils', 'undefined_job_hunt, array empty from backend');
+ }
+
return jobArray.map((job) => {
- return { ...job, [prop]: job[prop].nodes.map((item) => item[field]) };
+ return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') };
});
};
@@ -17,20 +35,34 @@ const unwrapJobWithNeeds = (denodedJobArray) => {
return unwrapNodesWithName(denodedJobArray, 'needs');
};
-const unwrapStagesWithNeeds = (denodedStages) => {
+const unwrapStagesWithNeedsAndLookup = (denodedStages) => {
const unwrappedNestedGroups = unwrapGroups(denodedStages);
- const nodes = unwrappedNestedGroups.map((node) => {
+ const lookupMap = {};
+
+ const nodes = unwrappedNestedGroups.map(({ node, lookup }) => {
const { groups } = node;
- const groupsWithJobs = groups.map((group) => {
+ const groupsWithJobs = groups.map((group, idx) => {
const jobs = unwrapJobWithNeeds(group.jobs.nodes);
+
+ lookupMap[group.name] = { ...lookup, groupIdx: idx };
return { ...group, jobs };
});
return { ...node, groups: groupsWithJobs };
});
- return nodes;
+ return { stages: nodes, lookup: lookupMap };
+};
+
+const unwrapStagesWithNeeds = (denodedStages) => {
+ return unwrapStagesWithNeedsAndLookup(denodedStages).stages;
};
-export { unwrapGroups, unwrapNodesWithName, unwrapJobWithNeeds, unwrapStagesWithNeeds };
+export {
+ unwrapGroups,
+ unwrapJobWithNeeds,
+ unwrapNodesWithName,
+ unwrapStagesWithNeeds,
+ unwrapStagesWithNeedsAndLookup,
+};
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index 21b114825a6..01705e7726f 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -35,3 +35,6 @@ export const POST_FAILURE = 'post_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
export const CHILD_VIEW = 'child';
+
+// The key of the template is the same as the filename
+export const HELLO_WORLD_TEMPLATE_KEY = 'Hello-World';
diff --git a/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
new file mode 100644
index 00000000000..e4fd55a28be
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/mutations/dismiss_pipeline_notification.graphql
@@ -0,0 +1,5 @@
+mutation DismissPipelineNotification($featureName: String!) {
+ userCalloutCreate(input: { featureName: $featureName }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
index c73b186739e..887c217da41 100644
--- a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql
@@ -1,6 +1,7 @@
query getDagVisData($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) {
pipeline(iid: $iid) {
+ id
stages {
nodes {
name
diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
new file mode 100644
index 00000000000..12b391e41ac
--- /dev/null
+++ b/app/assets/javascripts/pipelines/graphql/queries/get_user_callouts.query.graphql
@@ -0,0 +1,13 @@
+query getUser {
+ currentUser {
+ id
+ __typename
+ callouts {
+ __typename
+ nodes {
+ __typename
+ featureName
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
index 2321728e30c..d9c9289f66e 100644
--- a/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
+++ b/app/assets/javascripts/pipelines/mixins/pipelines_mixin.js
@@ -190,7 +190,7 @@ export default {
.then(() => this.updateTable())
.catch(() => {
createFlash(
- __('An error occurred while trying to run a new pipeline for this Merge Request.'),
+ __('An error occurred while trying to run a new pipeline for this merge request.'),
);
})
.finally(() => this.store.toggleIsRunningPipeline(false));
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index c3444f38ea0..a2bc049c3c7 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -3,11 +3,15 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import { __ } from '~/locale';
import Translate from '~/vue_shared/translate';
import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue';
-import { reportToSentry } from './components/graph/utils';
import TestReports from './components/test_reports/test_reports.vue';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import createDagApp from './pipeline_details_dag';
+import { createPipelinesDetailApp } from './pipeline_details_graph';
+import { createPipelineHeaderApp } from './pipeline_details_header';
+import { createPipelineNotificationApp } from './pipeline_details_notification';
+import { apolloProvider } from './pipeline_shared_client';
import createTestReportsStore from './stores/test_reports';
+import { reportToSentry } from './utils';
Vue.use(Translate);
@@ -15,6 +19,7 @@ const SELECTORS = {
PIPELINE_DETAILS: '.js-pipeline-details-vue',
PIPELINE_GRAPH: '#js-pipeline-graph-vue',
PIPELINE_HEADER: '#js-pipeline-header-vue',
+ PIPELINE_NOTIFICATION: '#js-pipeline-notification',
PIPELINE_TESTS: '#js-pipeline-tests-detail',
};
@@ -79,21 +84,28 @@ const createTestDetails = () => {
};
export default async function initPipelineDetailsBundle() {
- createTestDetails();
- createDagApp();
-
const canShowNewPipelineDetails =
gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers;
const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS);
- if (canShowNewPipelineDetails) {
+ try {
+ createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER, apolloProvider, dataset.graphqlResourceEtag);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
+
+ if (gon.features.pipelineGraphLayersView) {
try {
- const { createPipelinesDetailApp } = await import(
- /* webpackChunkName: 'createPipelinesDetailApp' */ './pipeline_details_graph'
- );
+ createPipelineNotificationApp(SELECTORS.PIPELINE_NOTIFICATION, apolloProvider);
+ } catch {
+ Flash(__('An error occurred while loading a section of this page.'));
+ }
+ }
- createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, dataset);
+ if (canShowNewPipelineDetails) {
+ try {
+ createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset);
} catch {
Flash(__('An error occurred while loading the pipeline.'));
}
@@ -107,12 +119,6 @@ export default async function initPipelineDetailsBundle() {
createLegacyPipelinesDetailApp(mediator);
}
- try {
- const { createPipelineHeaderApp } = await import(
- /* webpackChunkName: 'createPipelineHeaderApp' */ './pipeline_details_header'
- );
- createPipelineHeaderApp(SELECTORS.PIPELINE_HEADER);
- } catch {
- Flash(__('An error occurred while loading a section of this page.'));
- }
+ createDagApp(apolloProvider);
+ createTestDetails();
}
diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js
index 4ee0ad462d2..e2835ecc4d1 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_dag.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js
@@ -1,15 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import Dag from './components/dag/dag.vue';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-const createDagApp = () => {
+const createDagApp = (apolloProvider) => {
const el = document.querySelector('#js-pipeline-dag-vue');
if (!el) {
diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js
index 9eba39738dc..39c3c2ea5c5 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_graph.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js
@@ -1,23 +1,14 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import { GRAPHQL } from './components/graph/constants';
import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue';
-import { reportToSentry } from './components/graph/utils';
+import { reportToSentry } from './utils';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- useGet: true,
- },
- ),
-});
-
const createPipelinesDetailApp = (
selector,
+ apolloProvider,
{ pipelineProjectPath, pipelineIid, metricsPath, graphqlResourceEtag } = {},
) => {
// eslint-disable-next-line no-new
diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js
index cba29acdb32..1c619768764 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_header.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_header.js
@@ -1,15 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import createDefaultClient from '~/lib/graphql';
import pipelineHeader from './components/header_component.vue';
Vue.use(VueApollo);
-const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(),
-});
-
-export const createPipelineHeaderApp = (elSelector) => {
+export const createPipelineHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => {
const el = document.querySelector(elSelector);
if (!el) {
@@ -27,6 +22,7 @@ export const createPipelineHeaderApp = (elSelector) => {
provide: {
paths: {
fullProject: fullPath,
+ graphqlResourceEtag,
pipelinesPath,
},
pipelineId,
diff --git a/app/assets/javascripts/pipelines/pipeline_details_notification.js b/app/assets/javascripts/pipelines/pipeline_details_notification.js
new file mode 100644
index 00000000000..be234e8972d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_details_notification.js
@@ -0,0 +1,29 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import PipelineNotification from './components/notification/pipeline_notification.vue';
+
+Vue.use(VueApollo);
+
+export const createPipelineNotificationApp = (elSelector, apolloProvider) => {
+ const el = document.querySelector(elSelector);
+
+ if (!el) {
+ return;
+ }
+
+ const { dagDocPath } = el?.dataset;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ PipelineNotification,
+ },
+ provide: {
+ dagDocPath,
+ },
+ apolloProvider,
+ render(createElement) {
+ return createElement('pipeline-notification');
+ },
+ });
+};
diff --git a/app/assets/javascripts/pipelines/pipeline_shared_client.js b/app/assets/javascripts/pipelines/pipeline_shared_client.js
new file mode 100644
index 00000000000..c3be487caae
--- /dev/null
+++ b/app/assets/javascripts/pipelines/pipeline_shared_client.js
@@ -0,0 +1,11 @@
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ useGet: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/pipelines/pipelines_index.js b/app/assets/javascripts/pipelines/pipelines_index.js
index 0e2e9785956..9ed4365ad75 100644
--- a/app/assets/javascripts/pipelines/pipelines_index.js
+++ b/app/assets/javascripts/pipelines/pipelines_index.js
@@ -27,6 +27,8 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
errorStateSvgPath,
noPipelinesSvgPath,
newPipelinePath,
+ addCiYmlPath,
+ suggestedCiTemplates,
canCreatePipeline,
hasGitlabCi,
ciLintPath,
@@ -37,6 +39,10 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => {
return new Vue({
el,
+ provide: {
+ addCiYmlPath,
+ suggestedCiTemplates: JSON.parse(suggestedCiTemplates),
+ },
data() {
return {
store: new PipelinesStore(),
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
index 22820fca43e..0a6c326fa3d 100644
--- a/app/assets/javascripts/pipelines/utils.js
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -1,3 +1,4 @@
+import * as Sentry from '@sentry/browser';
import { pickBy } from 'lodash';
import { createNodeDict } from './components/parsing_utils';
import { SUPPORTED_FILTER_PARAMETERS } from './constants';
@@ -65,3 +66,10 @@ export const generateJobNeedsDict = (jobs = {}) => {
return { ...acc, [value]: uniqueValues };
}, {});
};
+
+export const reportToSentry = (component, failureType) => {
+ Sentry.withScope((scope) => {
+ scope.setTag('component', component);
+ Sentry.captureException(failureType);
+ });
+};