diff options
author | Robert Speicher <rspeicher@gmail.com> | 2021-01-20 22:34:23 +0300 |
---|---|---|
committer | Robert Speicher <rspeicher@gmail.com> | 2021-01-20 22:34:23 +0300 |
commit | 6438df3a1e0fb944485cebf07976160184697d72 (patch) | |
tree | 00b09bfd170e77ae9391b1a2f5a93ef6839f2597 /app/assets/javascripts/pipelines | |
parent | 42bcd54d971da7ef2854b896a7b34f4ef8601067 (diff) |
Add latest changes from gitlab-org/gitlab@13-8-stable-eev13.8.0-rc42
Diffstat (limited to 'app/assets/javascripts/pipelines')
49 files changed, 737 insertions, 386 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 85171263f08..2482af2c7f0 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -56,15 +56,15 @@ export default { const unwrappedGroups = stages .map(({ name, groups: { nodes: groups } }) => { - return groups.map(group => { + return groups.map((group) => { return { category: name, ...group }; }); }) .flat(2); - const nodes = unwrappedGroups.map(group => { + const nodes = unwrappedGroups.map((group) => { const jobs = group.jobs.nodes.map(({ name, needs }) => { - return { name, needs: needs.nodes.map(need => need.name) }; + return { name, needs: needs.nodes.map((need) => need.name) }; }); return { ...group, jobs }; diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue index 42d1debcddf..5ba0604fa01 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue @@ -173,7 +173,7 @@ export default { createClip(link) { return link .append('clipPath') - .attr('id', d => { + .attr('id', (d) => { return this.createAndAssignId(d, 'clipId', 'dag-clip'); }) .append('path') @@ -183,7 +183,7 @@ export default { createGradient(link) { const gradient = link .append('linearGradient') - .attr('id', d => { + .attr('id', (d) => { return this.createAndAssignId(d, 'gradId', 'dag-grad'); }) .attr('gradientUnits', 'userSpaceOnUse') @@ -251,7 +251,7 @@ export default { .data(linksData) .enter() .append('g') - .attr('id', d => { + .attr('id', (d) => { return this.createAndAssignId(d, 'uid', LINK_SELECTOR); }) .classed( @@ -273,10 +273,10 @@ export default { `${NODE_SELECTOR} gl-transition-property-stroke ${this.$options.viewOptions.hoverFadeClasses}`, true, ) - .attr('id', d => { + .attr('id', (d) => { return this.createAndAssignId(d, 'uid', NODE_SELECTOR); }) - .attr('stroke', d => { + .attr('stroke', (d) => { const color = this.color(d); /* eslint-disable-next-line no-param-reassign */ d.color = color; @@ -284,10 +284,10 @@ export default { }) .attr('stroke-width', nodeWidth) .attr('stroke-linecap', 'round') - .attr('x1', d => Math.floor((d.x1 + d.x0) / 2)) - .attr('x2', d => Math.floor((d.x1 + d.x0) / 2)) - .attr('y1', d => d.y0 + 4) - .attr('y2', d => d.y1 - 4); + .attr('x1', (d) => Math.floor((d.x1 + d.x0) / 2)) + .attr('x2', (d) => Math.floor((d.x1 + d.x0) / 2)) + .attr('y1', (d) => d.y0 + 4) + .attr('y2', (d) => d.y1 - 4); }, initColors() { diff --git a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js index d56addc473f..3cd09d57ffb 100644 --- a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js @@ -92,8 +92,8 @@ export const createSankey = ({ ]); return ({ nodes, links }) => sankeyGenerator({ - nodes: nodes.map(d => ({ ...d })), - links: links.map(d => ({ ...d })), + nodes: nodes.map((d) => ({ ...d })), + links: links.map((d) => ({ ...d })), }); }; diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js index e9f3e9f0e2c..69f36feeee4 100644 --- a/app/assets/javascripts/pipelines/components/dag/interactions.js +++ b/app/assets/javascripts/pipelines/components/dag/interactions.js @@ -13,22 +13,22 @@ export const getLiveLinksAsDict = () => { return Object.fromEntries( getLiveLinks() .data() - .map(d => [d.uid, d]), + .map((d) => [d.uid, d]), ); }; export const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED); -const backgroundLinks = selection => selection.style('stroke-opacity', highlightOut); -const backgroundNodes = selection => selection.attr('stroke', '#f2f2f2'); -const foregroundLinks = selection => selection.style('stroke-opacity', highlightIn); -const foregroundNodes = selection => selection.attr('stroke', d => d.color); +const backgroundLinks = (selection) => selection.style('stroke-opacity', highlightOut); +const backgroundNodes = (selection) => selection.attr('stroke', '#f2f2f2'); +const foregroundLinks = (selection) => selection.style('stroke-opacity', highlightIn); +const foregroundNodes = (selection) => selection.attr('stroke', (d) => d.color); const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity); -const renewNodes = selection => selection.attr('stroke', d => d.color); +const renewNodes = (selection) => selection.attr('stroke', (d) => d.color); -export const getAllLinkAncestors = node => { +export const getAllLinkAncestors = (node) => { if (node.targetLinks) { - return node.targetLinks.flatMap(n => { + return node.targetLinks.flatMap((n) => { return [n, ...getAllLinkAncestors(n.source)]; }); } @@ -36,11 +36,11 @@ export const getAllLinkAncestors = node => { return []; }; -const getAllNodeAncestors = node => { +const getAllNodeAncestors = (node) => { let allNodes = []; if (node.targetLinks) { - allNodes = node.targetLinks.flatMap(n => { + allNodes = node.targetLinks.flatMap((n) => { return getAllNodeAncestors(n.source); }); } @@ -74,7 +74,7 @@ const highlightPath = (parentLinks, parentNodes) => { }); /* highlight correct nodes */ - parentNodes.forEach(id => { + parentNodes.forEach((id) => { foregroundNodes(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); }); }; @@ -86,7 +86,7 @@ const restoreNodes = () => { rehighlights their nodes. */ - getLiveLinks().each(d => { + getLiveLinks().each((d) => { foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true); foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true); }); @@ -97,7 +97,7 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => { renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false); }); - parentNodes.forEach(id => { + parentNodes.forEach((id) => { d3.select(`#${id}`).classed(IS_HIGHLIGHTED, false); }); @@ -112,7 +112,7 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => { restoreNodes(); }; -export const restoreLinks = baseOpacity => { +export const restoreLinks = (baseOpacity) => { /* if there exist live links, reset to highlight out / pale otherwise, reset to base diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index 4e9b21a5c55..0ce94d4f02f 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -4,6 +4,7 @@ import axios from '~/lib/utils/axios_utils'; import { dasherize } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { reportToSentry } from './utils'; /** * Renders either a cancel, retry or play icon button and handles the post request @@ -50,6 +51,9 @@ export default { return `${actionIconDash} js-icon-${actionIconDash}`; }, }, + errorCaptured(err, _vm, info) { + reportToSentry('action_component', `error: ${err}, info: ${info}`); + }, methods: { /** * The request should not be handled here. @@ -70,10 +74,12 @@ export default { this.$emit('pipelineActionRequestComplete'); }) - .catch(() => { + .catch((err) => { this.isDisabled = false; this.isLoading = false; + reportToSentry('action_component', err); + createFlash(__('An error occurred while making the request.')); }); }, diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 67b2ed3b596..cd403757fe6 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -1,12 +1,15 @@ <script> import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue'; +import LinksLayer from '../graph_shared/links_layer.vue'; import LinkedPipelinesColumn from './linked_pipelines_column.vue'; import StageColumnComponent from './stage_column_component.vue'; import { DOWNSTREAM, MAIN, UPSTREAM } from './constants'; +import { reportToSentry } from './utils'; export default { name: 'PipelineGraph', components: { + LinksLayer, LinkedGraphWrapper, LinkedPipelinesColumn, StageColumnComponent, @@ -31,9 +34,16 @@ export default { DOWNSTREAM, UPSTREAM, }, + CONTAINER_REF: 'PIPELINE_LINKS_CONTAINER_REF', + BASE_CONTAINER_ID: 'pipeline-links-container', data() { return { hoveredJobName: '', + highlightedJobs: [], + measurements: { + width: 0, + height: 0, + }, pipelineExpanded: { jobName: '', expanded: false, @@ -41,6 +51,9 @@ export default { }; }, computed: { + containerId() { + return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`; + }, downstreamPipelines() { return this.hasDownstreamPipelines ? this.pipeline.downstream : []; }, @@ -53,12 +66,13 @@ export default { hasUpstreamPipelines() { return Boolean(this.pipeline?.upstream?.length > 0); }, - // The two show checks prevent upstream / downstream from showing redundant linked columns + // The show downstream check prevents showing redundant linked columns showDownstreamPipelines() { return ( this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM ); }, + // The show upstream check prevents showing redundant linked columns showUpstreamPipelines() { return ( this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM @@ -68,7 +82,22 @@ export default { return this.hasUpstreamPipelines ? this.pipeline.upstream : []; }, }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, + mounted() { + this.measurements = this.getMeasurements(); + }, methods: { + getMeasurements() { + return { + width: this.$refs[this.containerId].scrollWidth, + height: this.$refs[this.containerId].scrollHeight, + }; + }, + onError(errorType) { + this.$emit('error', errorType); + }, setJob(jobName) { this.hoveredJobName = jobName; }, @@ -78,14 +107,17 @@ export default { jobName: expanded ? jobName : '', }; }, + updateHighlightedJobs(jobs) { + this.highlightedJobs = jobs; + }, }, }; </script> <template> <div class="js-pipeline-graph"> <div - class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" - :class="{ 'gl-py-5': !isLinkedPipeline }" + class="gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" + :class="{ 'gl-pipeline-min-h gl-py-5': !isLinkedPipeline }" > <linked-graph-wrapper> <template #upstream> @@ -94,20 +126,36 @@ export default { :linked-pipelines="upstreamPipelines" :column-title="__('Upstream')" :type="$options.pipelineTypeConstants.UPSTREAM" - @error="emit('error', errorType)" + @error="onError" /> </template> <template #main> - <stage-column-component - v-for="stage in graph" - :key="stage.name" - :title="stage.name" - :groups="stage.groups" - :action="stage.status.action" - :job-hovered="hoveredJobName" - :pipeline-expanded="pipelineExpanded" - @refreshPipelineGraph="$emit('refreshPipelineGraph')" - /> + <div :id="containerId" :ref="containerId"> + <links-layer + :pipeline-data="graph" + :pipeline-id="pipeline.id" + :container-id="containerId" + :container-measurements="measurements" + :highlighted-job="hoveredJobName" + 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" + :highlighted-jobs="highlightedJobs" + :job-hovered="hoveredJobName" + :pipeline-expanded="pipelineExpanded" + :pipeline-id="pipeline.id" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @jobHover="setJob" + /> + </links-layer> + </div> </template> <template #downstream> <linked-pipelines-column @@ -117,7 +165,7 @@ export default { :type="$options.pipelineTypeConstants.DOWNSTREAM" @downstreamHovered="setJob" @pipelineExpandToggle="togglePipelineExpanded" - @error="emit('error', errorType)" + @error="onError" /> </template> </linked-graph-wrapper> 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 9ca4dc1e27a..2164dbf4d55 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue @@ -5,6 +5,7 @@ import StageColumnComponentLegacy from './stage_column_component_legacy.vue'; import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue'; import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin'; import { UPSTREAM, DOWNSTREAM, MAIN } from './constants'; +import { reportToSentry } from './utils'; export default { name: 'PipelineGraphLegacy', @@ -78,11 +79,11 @@ export default { return ( this.pipeline.triggered_by && Array.isArray(this.pipeline.triggered_by) && - this.pipeline.triggered_by.find(el => el.isExpanded) + this.pipeline.triggered_by.find((el) => el.isExpanded) ); }, expandedDownstream() { - return this.pipeline.triggered && this.pipeline.triggered.find(el => el.isExpanded); + return this.pipeline.triggered && this.pipeline.triggered.find((el) => el.isExpanded); }, pipelineTypeUpstream() { return this.type !== this.$options.downstream && this.expandedUpstream; @@ -94,6 +95,9 @@ export default { return this.pipeline.project.id; }, }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, methods: { capitalizeStageName(name) { const escapedName = escape(name); 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 d98e3aad054..f596333237d 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -1,10 +1,10 @@ <script> import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { __ } from '~/locale'; import { DEFAULT, LOAD_FAILURE } from '../../constants'; -import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql'; import PipelineGraph from './graph_component.vue'; -import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils'; +import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils'; export default { name: 'PipelineGraphWrapper', @@ -76,6 +76,9 @@ export default { mounted() { toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, methods: { hideAlert() { this.showAlert = false; @@ -86,6 +89,7 @@ export default { reportFailure(type) { this.showAlert = true; this.failureType = type; + reportToSentry(this.$options.name, this.failureType); }, }, }; 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 203d6a12edd..08d6162aeb8 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_group_dropdown.vue @@ -2,6 +2,7 @@ import { GlTooltipDirective } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import JobItem from './job_item.vue'; +import { reportToSentry } from './utils'; /** * Renders the dropdown for the pipeline graph. @@ -22,13 +23,24 @@ export default { type: Object, required: true, }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, }, computed: { + computedJobId() { + return this.pipelineId > -1 ? `${this.group.name}-${this.pipelineId}` : ''; + }, tooltipText() { const { name, status } = this.group; return `${name} - ${status.label}`; }, }, + errorCaptured(err, _vm, info) { + reportToSentry('job_group_dropdown', `error: ${err}, info: ${info}`); + }, methods: { pipelineActionRequestComplete() { this.$emit('pipelineActionRequestComplete'); @@ -37,7 +49,7 @@ export default { }; </script> <template> - <div class="ci-job-dropdown-container dropdown dropright"> + <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright"> <button v-gl-tooltip.hover="{ boundary: 'viewport' }" :title="tooltipText" diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index 93ebe02d4e8..8262d728a24 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -6,6 +6,7 @@ import { sprintf } from '~/locale'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { accessValue } from './accessors'; import { REST } from './constants'; +import { reportToSentry } from './utils'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -73,6 +74,11 @@ export default { required: false, default: () => ({}), }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, }, computed: { boundary() { @@ -84,6 +90,9 @@ export default { hasDetails() { return accessValue(this.dataMethod, 'hasDetails', this.status); }, + computedJobId() { + return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; + }, status() { return this.job && this.job.status ? this.job.status : {}; }, @@ -130,6 +139,9 @@ export default { : this.cssClassJobName; }, }, + errorCaptured(err, _vm, info) { + reportToSentry('job_item', `error: ${err}, info: ${info}`); + }, methods: { hideTooltips() { this.$root.$emit('bv::hide::tooltip'); @@ -142,6 +154,7 @@ export default { </script> <template> <div + :id="computedJobId" class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" data-qa-selector="job_item_container" > @@ -151,8 +164,7 @@ export default { :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" + 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" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 1a179de64cd..d18e604f087 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,9 +1,10 @@ <script> -import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlLink, GlLoadingIcon, GlBadge } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import { __, sprintf } from '~/locale'; import { accessValue } from './accessors'; import { DOWNSTREAM, REST, UPSTREAM } from './constants'; +import { reportToSentry } from './utils'; export default { directives: { @@ -14,6 +15,7 @@ export default { GlButton, GlLink, GlLoadingIcon, + GlBadge, }, inject: { dataMethod: { @@ -114,6 +116,9 @@ export default { return this.isUpstream ? 'gl-left-0 gl-border-r-1!' : 'gl-right-0 gl-border-l-1!'; }, }, + errorCaptured(err, _vm, info) { + reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`); + }, methods: { onClickLinkedPipeline() { this.hideTooltips(); @@ -168,7 +173,9 @@ export default { </div> </div> <div class="gl-pt-2"> - <span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span> + <gl-badge size="sm" variant="info" data-testid="downstream-pipeline-label"> + {{ label }} + </gl-badge> </div> <gl-button :id="buttonId" 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 7d333087874..40e6a01b88c 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -1,9 +1,9 @@ <script> -import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql'; +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import LinkedPipeline from './linked_pipeline.vue'; import { LOAD_FAILURE } from '../../constants'; import { UPSTREAM } from './constants'; -import { unwrapPipelineData, toggleQueryPollingByVisibility } from './utils'; +import { unwrapPipelineData, toggleQueryPollingByVisibility, reportToSentry } from './utils'; export default { components: { @@ -42,8 +42,8 @@ export default { computed: { columnClass() { const positionValues = { - right: 'gl-ml-11', - left: 'gl-mr-7', + right: 'gl-ml-6', + left: 'gl-mr-6', }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, @@ -80,8 +80,13 @@ export default { result() { this.loadingPipelineId = null; }, - error() { + error(err, _vm, _key, type) { this.$emit('error', LOAD_FAILURE); + + reportToSentry( + 'linked_pipelines_column', + `error type: ${LOAD_FAILURE}, error: ${err}, apollo error type: ${type}`, + ); }, }); 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 7d371b33220..2f1390e07d1 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,6 +1,7 @@ <script> import LinkedPipeline from './linked_pipeline.vue'; import { UPSTREAM } from './constants'; +import { reportToSentry } from './utils'; export default { components: { @@ -42,6 +43,9 @@ export default { return this.type === UPSTREAM; }, }, + errorCaptured(err, _vm, info) { + reportToSentry('linked_pipelines_column_legacy', `error: ${err}, info: ${info}`); + }, methods: { onPipelineClick(downstreamNode, pipeline, index) { this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); 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 b9bddc94ce4..65f8c231885 100644 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/stage_column_component.vue @@ -6,6 +6,7 @@ import JobGroupDropdown from './job_group_dropdown.vue'; import ActionComponent from './action_component.vue'; import { GRAPHQL } from './constants'; import { accessValue } from './accessors'; +import { reportToSentry } from './utils'; export default { components: { @@ -15,19 +16,28 @@ export default { MainGraphWrapper, }, props: { - title: { - type: String, - required: true, - }, groups: { type: Array, required: true, }, + pipelineId: { + type: Number, + required: true, + }, + title: { + type: String, + required: true, + }, action: { type: Object, required: false, default: () => ({}), }, + highlightedJobs: { + type: Array, + required: false, + default: () => [], + }, jobHovered: { type: String, required: false, @@ -54,6 +64,9 @@ export default { return !isEmpty(this.action); }, }, + errorCaptured(err, _vm, info) { + reportToSentry('stage_column_component', `error: ${err}, info: ${info}`); + }, methods: { getGroupId(group) { return accessValue(GRAPHQL, 'groupId', group); @@ -61,11 +74,18 @@ export default { groupId(group) { return `ci-badge-${escape(group.name)}`; }, + isFadedOut(jobName) { + return ( + this.jobHovered && + this.highlightedJobs.length > 1 && + !this.highlightedJobs.includes(jobName) + ); + }, }, }; </script> <template> - <main-graph-wrapper> + <main-graph-wrapper class="gl-px-6"> <template #stages> <div data-testid="stage-column-title" @@ -90,16 +110,25 @@ export default { :key="getGroupId(group)" data-testid="stage-column-group" class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width" + @mouseenter="$emit('jobHover', group.name)" + @mouseleave="$emit('jobHover', '')" > <job-item v-if="group.size === 1" :job="group.jobs[0]" :job-hovered="jobHovered" :pipeline-expanded="pipelineExpanded" + :pipeline-id="pipelineId" css-class-job-name="gl-build-content" + :class="{ 'gl-opacity-3': isFadedOut(group.name) }" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" /> - <job-group-dropdown v-else :group="group" /> + <job-group-dropdown + v-else + :group="group" + :pipeline-id="pipelineId" + :class="{ 'gl-opacity-3': isFadedOut(group.name) }" + /> </div> </template> </main-graph-wrapper> 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 258b6bf6b6d..059e8f9f8db 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 @@ -4,6 +4,7 @@ import stageColumnMixin from '../../mixins/stage_column_mixin'; import JobItem from './job_item.vue'; import JobGroupDropdown from './job_group_dropdown.vue'; import ActionComponent from './action_component.vue'; +import { reportToSentry } from './utils'; export default { components: { @@ -52,6 +53,9 @@ export default { return !isEmpty(this.action); }, }, + errorCaptured(err, _vm, info) { + reportToSentry('stage_column_component_legacy', `error: ${err}, info: ${info}`); + }, methods: { groupId(group) { return `ci-badge-${escape(group.name)}`; diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 32588feb426..1a935599bfa 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,5 +1,6 @@ import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import * as Sentry from '~/sentry/wrapper'; import { unwrapStagesWithNeeds } from '../unwrapping_utils'; const addMulti = (mainPipelineProjectPath, linkedPipeline) => { @@ -9,7 +10,7 @@ const addMulti = (mainPipelineProjectPath, linkedPipeline) => { }; }; -const transformId = linkedPipeline => { +const transformId = (linkedPipeline) => { return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) }; }; @@ -42,7 +43,7 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => { }; const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => { - const stopStartQuery = query => { + const stopStartQuery = (query) => { if (!Visibility.hidden()) { query.startPolling(interval); } else { @@ -55,3 +56,10 @@ const toggleQueryPollingByVisibility = (queryRef, interval = 10000) => { }; export { unwrapPipelineData, toggleQueryPollingByVisibility }; + +export const reportToSentry = (component, failureType) => { + Sentry.withScope((scope) => { + scope.setTag('component', component); + Sentry.captureException(failureType); + }); +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 35230e1511b..65c215be794 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -1,5 +1,7 @@ import * as d3 from 'd3'; -import { createUniqueLinkId } from '../../utils'; + +export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`; + /** * This function expects its first argument data structure * to be the same shaped as the one generated by `parseData`, @@ -7,21 +9,23 @@ import { createUniqueLinkId } from '../../utils'; * we find the nodes in the graph, calculate their coordinates and * trace the lines that represent the needs of each job. * @param {Object} nodeDict - Resulting object of `parseData` with nodes and links - * @param {Object} jobs - An object where each key is the job name that contains the job data - * @param {ref} svg - Reference to the svg we draw in + * @param {String} containerID - Id for the svg the links will be draw in * @returns {Array} Links that contain all the information about them */ -export const generateLinksData = ({ links }, containerID) => { +export const generateLinksData = ({ links }, containerID, modifier = '') => { const containerEl = document.getElementById(containerID); - return links.map(link => { + return links.map((link) => { const path = d3.path(); const sourceId = link.source; const targetId = link.target; - const sourceNodeEl = document.getElementById(sourceId); - const targetNodeEl = document.getElementById(targetId); + const modifiedSourceId = `${sourceId}${modifier}`; + const modifiedTargetId = `${targetId}${modifier}`; + + const sourceNodeEl = document.getElementById(modifiedSourceId); + const targetNodeEl = document.getElementById(modifiedTargetId); const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect(); const targetNodeCoordinates = targetNodeEl.getBoundingClientRect(); @@ -35,17 +39,11 @@ export const generateLinksData = ({ links }, containerID) => { // from the total to make sure it's aligned properly. We then make the line // positioned in the center of the job node by adding half the height // of the job pill. - const paddingLeft = Number( - window - .getComputedStyle(containerEl, null) - .getPropertyValue('padding-left') - .replace('px', ''), + const paddingLeft = parseFloat( + window.getComputedStyle(containerEl, null).getPropertyValue('padding-left'), ); - const paddingTop = Number( - window - .getComputedStyle(containerEl, null) - .getPropertyValue('padding-top') - .replace('px', ''), + const paddingTop = parseFloat( + window.getComputedStyle(containerEl, null).getPropertyValue('padding-top'), ); const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft; @@ -66,7 +64,10 @@ export const generateLinksData = ({ links }, containerID) => { // Make cross-stages lines a straight line all the way // until we can safely draw the bezier to look nice. - const straightLineDestinationX = targetNodeX - 100; + // The adjustment number here is a magic number to make things + // look nice and should change if the padding changes. This goes well + // with gl-px-6. gl-px-8 is more like 100. + const straightLineDestinationX = targetNodeX - 60; const controlPointX = straightLineDestinationX + (targetNodeX - straightLineDestinationX) / 2; if (straightLineDestinationX > 0) { diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue new file mode 100644 index 00000000000..89444076ae0 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -0,0 +1,140 @@ +<script> +import { isEmpty } from 'lodash'; +import { DRAW_FAILURE } from '../../constants'; +import { createJobsHash, generateJobNeedsDict } from '../../utils'; +import { parseData } from '../parsing_utils'; +import { generateLinksData } from './drawing_utils'; + +export default { + name: 'LinksInner', + STROKE_WIDTH: 2, + props: { + containerId: { + type: String, + required: true, + }, + containerMeasurements: { + type: Object, + required: true, + }, + pipelineId: { + type: Number, + required: true, + }, + pipelineData: { + type: Array, + required: true, + }, + defaultLinkColor: { + type: String, + required: false, + default: 'gl-stroke-gray-200', + }, + highlightedJob: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + links: [], + needsObject: null, + }; + }, + computed: { + hasHighlightedJob() { + return Boolean(this.highlightedJob); + }, + isPipelineDataEmpty() { + return isEmpty(this.pipelineData); + }, + 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.hasHighlightedJob + ? [this.highlightedJob, ...this.needsObject[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 []; + }, + viewBox() { + return [0, 0, this.containerMeasurements.width, this.containerMeasurements.height]; + }, + }, + watch: { + highlightedJob() { + // On first hover, generate the needs reference + if (!this.needsObject) { + const jobs = createJobsHash(this.pipelineData); + this.needsObject = generateJobNeedsDict(jobs) ?? {}; + } + }, + highlightedJobs(jobs) { + this.$emit('highlightedJobsChange', jobs); + }, + }, + mounted() { + if (!isEmpty(this.pipelineData)) { + this.prepareLinkData(); + } + }, + methods: { + isLinkHighlighted(linkRef) { + return this.highlightedLinks.includes(linkRef); + }, + prepareLinkData() { + try { + const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); + const parsedData = parseData(arrayOfJobs); + this.links = generateLinksData(parsedData, this.containerId, `-${this.pipelineId}`); + } catch { + this.$emit('error', DRAW_FAILURE); + } + }, + getLinkClasses(link) { + return [ + this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor, + { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) }, + ]; + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-relative"> + <svg + id="link-svg" + class="gl-absolute" + :viewBox="viewBox" + :width="`${containerMeasurements.width}px`" + :height="`${containerMeasurements.height}px`" + > + <template> + <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" + /> + </template> + </svg> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue new file mode 100644 index 00000000000..0993892a574 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -0,0 +1,86 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +import LinksInner from './links_inner.vue'; + +export default { + name: 'LinksLayer', + components: { + GlAlert, + LinksInner, + }, + MAX_GROUPS: 200, + props: { + containerMeasurements: { + type: Object, + required: true, + }, + pipelineData: { + type: Array, + required: true, + }, + }, + data() { + return { + alertDismissed: false, + showLinksOverride: false, + }; + }, + i18n: { + showLinksAnyways: __('Show links anyways'), + tooManyJobs: __( + 'This graph has a large number of jobs and showing the links between them may have performance implications.', + ), + }, + computed: { + containerZero() { + return !this.containerMeasurements.width || !this.containerMeasurements.height; + }, + numGroups() { + return this.pipelineData.reduce((acc, { groups }) => { + return acc + Number(groups.length); + }, 0); + }, + showAlert() { + return !this.showLinkedLayers && !this.alertDismissed; + }, + showLinkedLayers() { + return ( + !this.containerZero && (this.showLinksOverride || this.numGroups < this.$options.MAX_GROUPS) + ); + }, + }, + methods: { + dismissAlert() { + this.alertDismissed = true; + }, + overrideShowLinks() { + this.dismissAlert(); + this.showLinksOverride = true; + }, + }, +}; +</script> +<template> + <links-inner + v-if="showLinkedLayers" + :container-measurements="containerMeasurements" + :pipeline-data="pipelineData" + v-bind="$attrs" + v-on="$listeners" + > + <slot></slot> + </links-inner> + <div v-else> + <gl-alert + v-if="showAlert" + class="gl-w-max-content gl-ml-4" + :primary-button-text="$options.i18n.showLinksAnyways" + @primaryAction="overrideShowLinks" + @dismiss="dismissAlert" + > + {{ $options.i18n.tooManyJobs }} + </gl-alert> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue index 1c9e3236d56..bcd7705669e 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/main_graph_wrapper.vue @@ -16,14 +16,11 @@ export default { </script> <template> <div> - <div - class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-mb-5" - :class="stageClasses" - > + <div class="gl-display-flex gl-align-items-center gl-w-full gl-mb-5" :class="stageClasses"> <slot name="stages"> </slot> </div> <div - class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8" + class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full" :class="jobClasses" > <slot name="jobs"> </slot> diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index af7c0d0ec3f..a20bd70e90a 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -54,7 +54,7 @@ export default { iid: this.pipelineIid, }; }, - update: data => data.project.pipeline, + update: (data) => data.project.pipeline, error() { this.reportFailure(LOAD_FAILURE); }, diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index 1ed415688f2..9c97fa832d0 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -33,15 +33,15 @@ import { uniqWith, isEqual } from 'lodash'; 10 -> value (constant) */ -export const createNodeDict = nodes => { +export const createNodeDict = (nodes) => { return nodes.reduce((acc, node) => { const newNode = { ...node, - needs: node.jobs.map(job => job.needs || []).flat(), + needs: node.jobs.map((job) => job.needs || []).flat(), }; if (node.size > 1) { - node.jobs.forEach(job => { + node.jobs.forEach((job) => { acc[job.name] = newNode; }); } @@ -54,13 +54,13 @@ export const createNodeDict = nodes => { export const makeLinksFromNodes = (nodes, nodeDict) => { const constantLinkValue = 10; // all links are the same weight return nodes - .map(group => { - return group.jobs.map(job => { + .map((group) => { + return group.jobs.map((job) => { if (!job.needs) { return []; } - return job.needs.map(needed => { + return job.needs.map((needed) => { return { source: nodeDict[needed]?.name, target: group.name, @@ -74,7 +74,7 @@ export const makeLinksFromNodes = (nodes, nodeDict) => { export const getAllAncestors = (nodes, nodeDict) => { const needs = nodes - .map(node => { + .map((node) => { return nodeDict[node].needs || ''; }) .flat() @@ -102,13 +102,13 @@ export const filterByAncestors = (links, nodeDict) => */ const targetNode = target; const targetNodeNeeds = nodeDict[targetNode].needs; - const targetNodeNeedsMinusSource = targetNodeNeeds.filter(need => need !== source); + const targetNodeNeedsMinusSource = targetNodeNeeds.filter((need) => need !== source); const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict); return !allAncestors.includes(source); }); -export const parseData = nodes => { +export const parseData = (nodes) => { const nodeDict = createNodeDict(nodes); const allLinks = makeLinksFromNodes(nodes, nodeDict); const filteredLinks = filterByAncestors(allLinks, nodeDict); @@ -121,7 +121,7 @@ export const parseData = nodes => { The number of nodes in the most populous generation drives the height of the graph. */ -export const getMaxNodes = nodes => { +export const getMaxNodes = (nodes) => { const counts = nodes.reduce((acc, { layer }) => { if (!acc[layer]) { acc[layer] = 0; @@ -141,6 +141,6 @@ export const getMaxNodes = nodes => { to find nodes that have no relations. */ -export const removeOrphanNodes = sankeyfiedNodes => { - return sankeyfiedNodes.filter(node => node.sourceLinks.length || node.targetLinks.length); +export const removeOrphanNodes = (sankeyfiedNodes) => { + return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length); }; 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 73e5f2542fb..8636808b69e 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_graph/pipeline_graph.vue @@ -1,12 +1,10 @@ <script> -import { isEmpty } from 'lodash'; import { GlAlert } from '@gitlab/ui'; import { __ } from '~/locale'; +import { generateLinksData } from '../graph_shared/drawing_utils'; import JobPill from './job_pill.vue'; import StagePill from './stage_pill.vue'; -import { generateLinksData } from './drawing_utils'; import { parseData } from '../parsing_utils'; -import { unwrapArrayOfJobs } from '../unwrapping_utils'; import { DRAW_FAILURE, DEFAULT, INVALID_CI_CONFIG, EMPTY_PIPELINE_DATA } from '../../constants'; import { createJobsHash, generateJobNeedsDict } from '../../utils'; import { CI_CONFIG_STATUS_INVALID } from '~/pipeline_editor/constants'; @@ -23,8 +21,6 @@ export default { errorTexts: { [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), [DEFAULT]: __('An unknown error occurred.'), - }, - warningTexts: { [EMPTY_PIPELINE_DATA]: __( 'The visualization will appear in this tab when the CI/CD configuration file is populated with valid syntax.', ), @@ -47,21 +43,24 @@ export default { }; }, 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 && isEmpty(this.pipelineData?.stages); + return !this.isInvalidCiConfig && this.pipelineStages.length === 0; }, isInvalidCiConfig() { return this.pipelineData?.status === CI_CONFIG_STATUS_INVALID; }, - showAlert() { - return this.hasError || this.hasWarning; - }, hasError() { return this.failureType; }, - hasWarning() { - return this.warning; - }, hasHighlightedJob() { return Boolean(this.highlightedJob); }, @@ -73,26 +72,32 @@ export default { return this.warning; }, failure() { - const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT]; - - return { text, variant: 'danger', dismissible: true }; - }, - warning() { - if (this.isPipelineDataEmpty) { - return { - text: this.$options.warningTexts[EMPTY_PIPELINE_DATA], - variant: 'tip', - dismissible: false, - }; - } else if (this.isInvalidCiConfig) { - return { - text: this.$options.warningTexts[INVALID_CI_CONFIG], - variant: 'danger', - dismissible: false, - }; + switch (this.failureType) { + case DRAW_FAILURE: + return { + text: this.$options.errorTexts[DRAW_FAILURE], + 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], + variant: 'danger', + dismissible: true, + }; } - - return null; }, viewBox() { return [0, 0, this.width, this.height]; @@ -100,40 +105,45 @@ export default { 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.hasHighlightedJob - ? [this.highlightedJob, ...this.needsObject[this.highlightedJob]] - : []; + return [this.highlightedJob, ...this.needsObject[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 => { + const filteredLinks = this.links.filter((link) => { return ( this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target) ); }); - return filteredLinks.map(link => link.ref); + return filteredLinks.map((link) => link.ref); } return []; }, }, - mounted() { - if (!this.isPipelineDataEmpty && !this.isInvalidCiConfig) { - // This guarantee that all sub-elements are rendered - // https://v3.vuejs.org/api/options-lifecycle-hooks.html#mounted - this.$nextTick(() => { - this.getGraphDimensions(); - this.prepareLinkData(); - }); - } + 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(); + }); + } + }, + }, }, methods: { prepareLinkData() { try { - const arrayOfJobs = unwrapArrayOfJobs(this.pipelineData); + const arrayOfJobs = this.pipelineStages.flatMap(({ groups }) => groups); const parsedData = parseData(arrayOfJobs); this.links = generateLinksData(parsedData, this.$options.CONTAINER_ID); } catch { @@ -141,7 +151,7 @@ export default { } }, getStageBackgroundClasses(index) { - const { length } = this.pipelineData.stages; + const { length } = this.pipelineStages; // It's possible for a graph to have only one stage, in which // case we concatenate both the left and right rounding classes if (length === 1) { @@ -162,7 +172,7 @@ export default { // 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.pipelineData); + const jobs = createJobsHash(this.pipelineStages); this.needsObject = generateJobNeedsDict(jobs) ?? {}; } @@ -171,7 +181,7 @@ export default { removeHighlightNeeds() { this.highlightedJob = null; }, - getGraphDimensions() { + computeGraphDimensions() { this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}`; this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}`; }, @@ -199,7 +209,7 @@ export default { <template> <div> <gl-alert - v-if="showAlert" + v-if="hasError" :variant="alert.variant" :dismissible="alert.dismissible" @dismiss="alert.dismissible ? resetFailure : null" @@ -207,7 +217,7 @@ export default { {{ alert.text }} </gl-alert> <div - v-if="!hasWarning" + 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" @@ -227,7 +237,7 @@ export default { </template> </svg> <div - v-for="(stage, index) in pipelineData.stages" + v-for="(stage, index) in pipelineStages" :key="`${stage.name}-${index}`" class="gl-flex-direction-column" > 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 78b69073cd3..ee26ea2f007 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,25 @@ <script> import { GlButton } from '@gitlab/ui'; +import { isExperimentEnabled } from '~/lib/utils/experimentation'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; export default { + i18n: { + control: { + infoMessage: s__(`Pipelines|Continuous Integration can help + catch bugs by running your tests automatically, + while Continuous Deployment can help you deliver + code to your product environment.`), + buttonMessage: s__('Pipelines|Get started with Pipelines'), + }, + experiment: { + infoMessage: s__(`Pipelines|GitLab CI/CD can automatically build, + test, and deploy your code. Let GitLab take care of time + consuming tasks, so you can spend more time creating.`), + buttonMessage: s__('Pipelines|Get started with CI/CD'), + }, + }, name: 'PipelinesEmptyState', components: { GlButton, @@ -20,6 +38,23 @@ export default { required: true, }, }, + mounted() { + this.track('viewed'); + }, + methods: { + track(action) { + if (!gon.tracking_data) { + return; + } + + const { category, value, label, property } = gon.tracking_data; + + Tracking.event(category, action, { value, label, property }); + }, + isExperimentEnabled() { + return isExperimentEnabled('pipelinesEmptyState'); + }, + }, }; </script> <template> @@ -29,18 +64,16 @@ export default { </div> <div class="col-12"> - <div class="gl-text-content"> + <div class="text-content"> <template v-if="canSetCi"> - <h4 class="gl-text-center" data-testid="header-text"> + <h4 data-testid="header-text" class="gl-text-center"> {{ s__('Pipelines|Build with confidence') }} </h4> - <p data-testid="info-text"> {{ - s__(`Pipelines|Continuous Integration can help - catch bugs by running your tests automatically, - while Continuous Deployment can help you deliver - code to your product environment.`) + isExperimentEnabled() + ? $options.i18n.experiment.infoMessage + : $options.i18n.control.infoMessage }} </p> @@ -50,8 +83,13 @@ export default { variant="info" category="primary" data-testid="get-started-pipelines" + @click="track('documentation_clicked')" > - {{ s__('Pipelines|Get started with Pipelines') }} + {{ + isExperimentEnabled() + ? $options.i18n.experiment.buttonMessage + : $options.i18n.control.buttonMessage + }} </gl-button> </div> </template> 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 bde0dd53aac..d1bac078642 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,5 +1,5 @@ <script> -import { GlLink, GlPopover, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; import { SCHEDULE_ORIGIN } from '../../constants'; export default { @@ -7,10 +7,16 @@ export default { GlLink, GlPopover, GlSprintf, + GlBadge, }, directives: { GlTooltip: GlTooltipDirective, }, + inject: { + targetProjectFullPath: { + default: '', + }, + }, props: { pipeline: { type: Object, @@ -25,11 +31,6 @@ export default { required: true, }, }, - inject: { - targetProjectFullPath: { - default: '', - }, - }, computed: { user() { return this.pipeline.user; @@ -50,7 +51,6 @@ export default { <div class="table-section section-10 d-none d-md-block pipeline-tags"> <gl-link :href="pipeline.path" - class="js-pipeline-url-link js-onboarding-pipeline-item" data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > @@ -58,46 +58,49 @@ export default { </gl-link> <div class="label-container"> <gl-link v-if="isScheduled" :href="pipelineScheduleUrl" target="__blank"> - <span + <gl-badge v-gl-tooltip :title="__('This pipeline was triggered by a schedule.')" - class="badge badge-info" + variant="info" + size="sm" data-testid="pipeline-url-scheduled" - >{{ __('Scheduled') }}</span + >{{ __('Scheduled') }}</gl-badge > </gl-link> - <span + <gl-badge v-if="pipeline.flags.latest" v-gl-tooltip :title="__('Latest pipeline for the most recent commit on this branch')" - class="js-pipeline-url-latest badge badge-success" + variant="success" + size="sm" data-testid="pipeline-url-latest" - >{{ __('latest') }}</span + >{{ __('latest') }}</gl-badge > - <span + <gl-badge v-if="pipeline.flags.yaml_errors" v-gl-tooltip :title="pipeline.yaml_errors" - class="js-pipeline-url-yaml badge badge-danger" + variant="danger" + size="sm" data-testid="pipeline-url-yaml" - >{{ __('yaml invalid') }}</span + >{{ __('yaml invalid') }}</gl-badge > - <span + <gl-badge v-if="pipeline.flags.failure_reason" v-gl-tooltip :title="pipeline.failure_reason" - class="js-pipeline-url-failure badge badge-danger" + variant="danger" + size="sm" data-testid="pipeline-url-failure" - >{{ __('error') }}</span + >{{ __('error') }}</gl-badge > <gl-link v-if="pipeline.flags.auto_devops" :id="`pipeline-url-autodevops-${pipeline.id}`" tabindex="0" - class="js-pipeline-url-autodevops badge badge-info autodevops-badge" data-testid="pipeline-url-autodevops" role="button" - >{{ __('Auto DevOps') }}</gl-link + ><gl-badge variant="info" size="sm">{{ __('Auto DevOps') }}</gl-badge></gl-link > <gl-popover :target="`pipeline-url-autodevops-${pipeline.id}`" @@ -113,7 +116,7 @@ export default { ) " > - <template #strong="{content}"> + <template #strong="{ content }"> <b>{{ content }}</b> </template> </gl-sprintf> @@ -123,13 +126,14 @@ export default { __('Learn more about Auto DevOps') }}</gl-link> </gl-popover> - <span + <gl-badge v-if="pipeline.flags.stuck" - class="js-pipeline-url-stuck badge badge-warning" + variant="warning" + size="sm" data-testid="pipeline-url-stuck" - >{{ __('stuck') }}</span + >{{ __('stuck') }}</gl-badge > - <span + <gl-badge v-if="pipeline.flags.detached_merge_request_pipeline" v-gl-tooltip :title=" @@ -137,17 +141,19 @@ export default { 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results.', ) " - class="js-pipeline-url-detached badge badge-info" + variant="info" + size="sm" data-testid="pipeline-url-detached" - >{{ __('detached') }}</span + >{{ __('detached') }}</gl-badge > - <span + <gl-badge v-if="isInFork" v-gl-tooltip :title="__('Pipeline ran in fork of project')" - class="badge badge-info" + variant="info" + size="sm" data-testid="pipeline-url-fork" - >{{ __('fork') }}</span + >{{ __('fork') }}</gl-badge > </div> </div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index ff27226b408..ec7c5764be1 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -246,7 +246,7 @@ export default { filterPipelines(filters) { this.resetRequestData(); - filters.forEach(filter => { + filters.forEach((filter) => { // do not add Any for username query param, so we // can fetch all trigger authors if ( @@ -279,7 +279,7 @@ export default { <div class="pipelines-container"> <div v-if="shouldRenderTabs || shouldRenderButtons" - class="top-area scrolling-tabs-container inner-page-scroll-tabs" + class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-none" > <div class="fade-left"><gl-icon name="chevron-lg-left" :size="12" /></div> <div class="fade-right"><gl-icon name="chevron-lg-right" :size="12" /></div> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue index 55c71e299be..b13460b4c68 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_artifacts.vue @@ -1,14 +1,19 @@ <script> -/* eslint-disable @gitlab/vue-require-i18n-strings */ -import { GlLink, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; export default { directives: { GlTooltip: GlTooltipDirective, }, components: { - GlIcon, - GlLink, + GlDropdown, + GlDropdownItem, + GlSprintf, + }, + translations: { + artifacts: __('Artifacts'), + downloadArtifact: __('Download %{name} artifact'), }, props: { artifacts: { @@ -19,24 +24,25 @@ export default { }; </script> <template> - <div class="btn-group" role="group"> - <button - v-gl-tooltip - type="button" - class="dropdown-toggle build-artifacts btn btn-default js-pipeline-dropdown-download" - :title="__('Artifacts')" - data-toggle="dropdown" - :aria-label="__('Artifacts')" + <gl-dropdown + v-gl-tooltip + class="build-artifacts js-pipeline-dropdown-download" + :title="$options.translations.artifacts" + :text="$options.translations.artifacts" + :aria-label="$options.translations.artifacts" + icon="download" + text-sr-only + > + <gl-dropdown-item + v-for="(artifact, i) in artifacts" + :key="i" + :href="artifact.path" + rel="nofollow" + download > - <gl-icon name="download" /> - <gl-icon name="chevron-down" /> - </button> - <ul class="dropdown-menu dropdown-menu-right"> - <li v-for="(artifact, i) in artifacts" :key="i"> - <gl-link :href="artifact.path" rel="nofollow" download - >Download {{ artifact.name }} artifact</gl-link - > - </li> - </ul> - </div> + <gl-sprintf :message="$options.translations.downloadArtifact"> + <template #name>{{ artifact.name }}</template> + </gl-sprintf> + </gl-dropdown-item> + </gl-dropdown> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 29345f33367..127503f1307 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -33,7 +33,7 @@ export default { }, computed: { selectedTypes() { - return this.value.map(i => i.type); + return this.value.map((i) => i.type); }, tokens() { return [ 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 index 7224ec455f6..b6c4e617a90 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table_row.vue @@ -346,7 +346,6 @@ export default { <pipelines-artifacts-component v-if="pipeline.details.artifacts.length" :artifacts="pipeline.details.artifacts" - class="d-md-block" /> <gl-button diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue index 581ea5fbb35..a9154d93194 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue @@ -124,7 +124,7 @@ export default { $( '.js-builds-dropdown-list button, .js-builds-dropdown-list a.mini-pipeline-graph-dropdown-item', this.$el, - ).on('click', e => { + ).on('click', (e) => { e.stopPropagation(); }); }, diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index 60cb697f1af..24456574a6f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -34,10 +34,10 @@ export default { fetchBranches(searchterm) { Api.branches(this.config.projectId, searchterm) .then(({ data }) => { - this.branches = data.map(branch => branch.name); + this.branches = data.map((branch) => branch.name); this.loading = false; }) - .catch(err => { + .catch((err) => { createFlash(FETCH_BRANCH_ERROR_MESSAGE); this.loading = false; throw err; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue index dc43d94f4fd..020a08b8cee 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue @@ -72,7 +72,7 @@ export default { ]; }, findActiveStatus() { - return this.statuses.find(status => status.value === this.value.data); + return this.statuses.find((status) => status.value === this.value.data); }, }, }; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index d6ba5fcca85..1241803c612 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -34,10 +34,10 @@ export default { fetchTags(searchTerm) { Api.tags(this.config.projectId, searchTerm) .then(({ data }) => { - this.tags = data.map(tag => tag.name); + this.tags = data.map((tag) => tag.name); this.loading = false; }) - .catch(err => { + .catch((err) => { createFlash(FETCH_TAG_ERROR_MESSAGE); this.loading = false; throw err; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index ae5758233bc..3db5893b565 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -45,7 +45,7 @@ export default { return this.value.data.toLowerCase(); }, activeUser() { - return this.users.find(user => { + return this.users.find((user) => { return user.username.toLowerCase() === this.currentValue; }); }, @@ -56,11 +56,11 @@ export default { methods: { fetchProjectUsers(searchTerm) { Api.projectUsers(this.config.projectId, searchTerm) - .then(users => { + .then((users) => { this.users = users; this.loading = false; }) - .catch(err => { + .catch((err) => { createFlash(FETCH_AUTHOR_ERROR_MESSAGE); this.loading = false; throw err; @@ -80,7 +80,7 @@ export default { v-on="$listeners" @input="searchAuthors" > - <template #view="{inputValue}"> + <template #view="{ inputValue }"> <gl-avatar v-if="activeUser" :size="16" diff --git a/app/assets/javascripts/pipelines/components/unwrapping_utils.js b/app/assets/javascripts/pipelines/components/unwrapping_utils.js index aa33f622ce6..15073079c0a 100644 --- a/app/assets/javascripts/pipelines/components/unwrapping_utils.js +++ b/app/assets/javascripts/pipelines/components/unwrapping_utils.js @@ -1,22 +1,5 @@ -/** - * This function takes the stages and add the stage name - * at the group level as `category` to have an easier - * implementation while constructions nodes with D3 - * @param {Array} stages - * @returns {Array} - Array of stages with stage name at the group level as `category` - */ -export const unwrapArrayOfJobs = (stages = []) => { - return stages - .map(({ name, groups }) => { - return groups.map(group => { - return { category: name, ...group }; - }); - }) - .flat(2); -}; - -const unwrapGroups = stages => { - return stages.map(stage => { +const unwrapGroups = (stages) => { + return stages.map((stage) => { const { groups: { nodes: groups }, } = stage; @@ -25,21 +8,21 @@ const unwrapGroups = stages => { }; const unwrapNodesWithName = (jobArray, prop, field = 'name') => { - return jobArray.map(job => { - return { ...job, [prop]: job[prop].nodes.map(item => item[field]) }; + return jobArray.map((job) => { + return { ...job, [prop]: job[prop].nodes.map((item) => item[field]) }; }); }; -const unwrapJobWithNeeds = denodedJobArray => { +const unwrapJobWithNeeds = (denodedJobArray) => { return unwrapNodesWithName(denodedJobArray, 'needs'); }; -const unwrapStagesWithNeeds = denodedStages => { +const unwrapStagesWithNeeds = (denodedStages) => { const unwrappedNestedGroups = unwrapGroups(denodedStages); - const nodes = unwrappedNestedGroups.map(node => { + const nodes = unwrappedNestedGroups.map((node) => { const { groups } = node; - const groupsWithJobs = groups.map(group => { + const groupsWithJobs = groups.map((group) => { const jobs = unwrapJobWithNeeds(group.jobs.nodes); return { ...group, jobs }; }); diff --git a/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql b/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql deleted file mode 100644 index 3bf6d8dc9d8..00000000000 --- a/app/assets/javascripts/pipelines/graphql/fragments/linked_pipelines.fragment.graphql +++ /dev/null @@ -1,17 +0,0 @@ -fragment LinkedPipelineData on Pipeline { - id - iid - path - status: detailedStatus { - group - label - icon - } - sourceJob { - name - } - project { - name - fullPath - } -} diff --git a/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql b/app/assets/javascripts/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql index 1da4fa0a72b..f93908aeb04 100644 --- a/app/assets/javascripts/pipelines/graphql/queries/pipeline_stages_connection.fragment.graphql +++ b/app/assets/javascripts/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql @@ -4,9 +4,23 @@ fragment PipelineStagesConnection on CiConfigStageConnection { groups { nodes { name + size jobs { nodes { name + script + beforeScript + afterScript + environment + allowFailure + tags + when + only { + refs + } + except { + refs + } needs { nodes { name diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql deleted file mode 100644 index 25aede49631..00000000000 --- a/app/assets/javascripts/pipelines/graphql/queries/get_pipeline_details.query.graphql +++ /dev/null @@ -1,65 +0,0 @@ -#import "../fragments/linked_pipelines.fragment.graphql" - -query getPipelineDetails($projectPath: ID!, $iid: ID!) { - project(fullPath: $projectPath) { - pipeline(iid: $iid) { - id - iid - downstream { - nodes { - ...LinkedPipelineData - } - } - upstream { - ...LinkedPipelineData - } - stages { - nodes { - name - status: detailedStatus { - action { - icon - path - title - } - } - groups { - nodes { - status: detailedStatus { - label - group - icon - } - name - size - jobs { - nodes { - name - scheduledAt - needs { - nodes { - name - } - } - status: detailedStatus { - icon - tooltip - hasDetails - detailsPath - group - action { - buttonTitle - icon - path - title - } - } - } - } - } - } - } - } - } - } -} diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js index bd1b1664a1e..9f15b6c4ae3 100644 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js @@ -6,7 +6,7 @@ export default { getExpandedPipelines(pipeline) { this.mediator.service .getPipeline(this.mediator.getExpandedParameters()) - .then(response => { + .then((response) => { this.mediator.store.toggleLoading(pipeline); this.mediator.store.storePipeline(response.data); this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index e31545bba5c..22cdb6b8f72 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -90,7 +90,7 @@ export default { // fetch new data return this.service .getPipelines(this.requestData) - .then(response => { + .then((response) => { this.isLoading = false; this.successCallback(response); @@ -124,8 +124,8 @@ export default { getPipelines() { return this.service .getPipelines(this.requestData) - .then(response => this.successCallback(response)) - .catch(error => this.errorCallback(error)); + .then((response) => this.successCallback(response)) + .catch((error) => this.errorCallback(error)); }, setCommonData(pipelines) { this.store.storePipelines(pipelines); diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index 27f71d2b878..133608b9801 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -10,6 +10,7 @@ import legacyPipelineHeader from './components/legacy_header_component.vue'; import eventHub from './event_hub'; import TestReports from './components/test_reports/test_reports.vue'; import createTestReportsStore from './stores/test_reports'; +import { reportToSentry } from './components/graph/utils'; Vue.use(Translate); @@ -20,7 +21,7 @@ const SELECTORS = { PIPELINE_TESTS: '#js-pipeline-tests-detail', }; -const createLegacyPipelinesDetailApp = mediator => { +const createLegacyPipelinesDetailApp = (mediator) => { if (!document.querySelector(SELECTORS.PIPELINE_GRAPH)) { return; } @@ -36,6 +37,9 @@ const createLegacyPipelinesDetailApp = mediator => { mediator, }; }, + errorCaptured(err, _vm, info) { + reportToSentry('pipeline_details_bundle_legacy_details', `error: ${err}, info: ${info}`); + }, render(createElement) { return createElement('pipeline-graph-legacy', { props: { @@ -47,15 +51,15 @@ const createLegacyPipelinesDetailApp = mediator => { refreshPipelineGraph: this.requestRefreshPipelineGraph, onResetDownstream: (parentPipeline, pipeline) => this.resetDownstreamPipelines(parentPipeline, pipeline), - onClickUpstreamPipeline: pipeline => this.clickUpstreamPipeline(pipeline), - onClickDownstreamPipeline: pipeline => this.clickDownstreamPipeline(pipeline), + onClickUpstreamPipeline: (pipeline) => this.clickUpstreamPipeline(pipeline), + onClickDownstreamPipeline: (pipeline) => this.clickDownstreamPipeline(pipeline), }, }); }, }); }; -const createLegacyPipelineHeaderApp = mediator => { +const createLegacyPipelineHeaderApp = (mediator) => { if (!document.querySelector(SELECTORS.PIPELINE_HEADER)) { return; } @@ -78,6 +82,9 @@ const createLegacyPipelineHeaderApp = mediator => { eventHub.$off('headerPostAction', this.postAction); eventHub.$off('headerDeleteAction', this.deleteAction); }, + errorCaptured(err, _vm, info) { + reportToSentry('pipeline_details_bundle_legacy', `error: ${err}, info: ${info}`); + }, methods: { postAction(path) { this.mediator.service @@ -125,7 +132,7 @@ const createTestDetails = () => { }); }; -export default async function() { +export default async function () { createTestDetails(); createDagApp(); @@ -151,7 +158,7 @@ export default async function() { ); const { pipelineProjectPath, pipelineIid } = dataset; - createPipelinesDetailApp(SELECTORS.PIPELINE_DETAILS, pipelineProjectPath, pipelineIid); + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, pipelineProjectPath, pipelineIid); } catch { Flash(__('An error occurred while loading the pipeline.')); } diff --git a/app/assets/javascripts/pipelines/pipeline_details_graph.js b/app/assets/javascripts/pipelines/pipeline_details_graph.js index 1b296c305cb..2d46bb5ec26 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_graph.js +++ b/app/assets/javascripts/pipelines/pipeline_details_graph.js @@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import PipelineGraphWrapper from './components/graph/graph_component_wrapper.vue'; import { GRAPHQL } from './components/graph/constants'; +import { reportToSentry } from './components/graph/utils'; Vue.use(VueApollo); @@ -28,6 +29,9 @@ const createPipelinesDetailApp = (selector, pipelineProjectPath, pipelineIid) => pipelineIid, dataMethod: GRAPHQL, }, + errorCaptured(err, _vm, info) { + reportToSentry('pipeline_details_graph', `error: ${err}, info: ${info}`); + }, render(createElement) { return createElement(PipelineGraphWrapper); }, diff --git a/app/assets/javascripts/pipelines/pipeline_details_header.js b/app/assets/javascripts/pipelines/pipeline_details_header.js index 744a8272709..cba29acdb32 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_header.js +++ b/app/assets/javascripts/pipelines/pipeline_details_header.js @@ -9,7 +9,7 @@ const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(), }); -export const createPipelineHeaderApp = elSelector => { +export const createPipelineHeaderApp = (elSelector) => { const el = document.querySelector(elSelector); if (!el) { diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index d487970aed7..74c5fc45644 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -55,7 +55,7 @@ export default class pipelinesMediator { return this.service .getPipeline() - .then(response => this.successCallback(response)) + .then((response) => this.successCallback(response)) .catch(() => this.errorCallback()) .finally(() => this.poll.restart( diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js index c6f65277c8d..1f804a107a8 100644 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ b/app/assets/javascripts/pipelines/stores/pipeline_store.js @@ -29,11 +29,11 @@ export default class PipelineStore { } if (pipelineCopy.triggered && pipelineCopy.triggered.length) { - pipelineCopy.triggered.forEach(el => { + pipelineCopy.triggered.forEach((el) => { const oldPipeline = this.state.pipeline && this.state.pipeline.triggered && - this.state.pipeline.triggered.find(element => element.id === el.id); + this.state.pipeline.triggered.find((element) => element.id === el.id); this.parseTriggeredPipelines(oldPipeline, el); }); @@ -67,8 +67,8 @@ export default class PipelineStore { } if (newPipeline.triggered_by?.length > 0) { - newPipeline.triggered_by.forEach(el => { - const oldTriggeredBy = oldPipeline.triggered_by?.find(element => element.id === el.id); + newPipeline.triggered_by.forEach((el) => { + const oldTriggeredBy = oldPipeline.triggered_by?.find((element) => element.id === el.id); this.parseTriggeredPipelines(oldTriggeredBy, el); }); } @@ -88,9 +88,9 @@ export default class PipelineStore { Vue.set(newPipeline, 'isLoading', false); if (newPipeline.triggered && newPipeline.triggered.length > 0) { - newPipeline.triggered.forEach(el => { + newPipeline.triggered.forEach((el) => { const oldTriggered = - oldPipeline.triggered && oldPipeline.triggered.find(element => element.id === el.id); + oldPipeline.triggered && oldPipeline.triggered.find((element) => element.id === el.id); this.parseTriggeredPipelines(oldTriggered, el); }); } @@ -102,7 +102,7 @@ export default class PipelineStore { * @param {Object} pipeline */ resetTriggeredByPipeline(parentPipeline, pipeline) { - parentPipeline.triggered_by.forEach(el => this.closePipeline(el)); + parentPipeline.triggered_by.forEach((el) => this.closePipeline(el)); if (pipeline.triggered_by && pipeline.triggered_by) { this.resetTriggeredByPipeline(pipeline, pipeline.triggered_by); @@ -129,7 +129,7 @@ export default class PipelineStore { this.closePipeline(pipeline); if (pipeline.triggered_by && pipeline.triggered_by.length) { - pipeline.triggered_by.forEach(triggeredBy => this.closeTriggeredByPipeline(triggeredBy)); + pipeline.triggered_by.forEach((triggeredBy) => this.closeTriggeredByPipeline(triggeredBy)); } } @@ -139,10 +139,10 @@ export default class PipelineStore { * @param {Object} pipeline */ resetTriggeredPipelines(parentPipeline, pipeline) { - parentPipeline.triggered.forEach(el => this.closePipeline(el)); + parentPipeline.triggered.forEach((el) => this.closePipeline(el)); if (pipeline.triggered && pipeline.triggered.length) { - pipeline.triggered.forEach(el => this.resetTriggeredPipelines(pipeline, el)); + pipeline.triggered.forEach((el) => this.resetTriggeredPipelines(pipeline, el)); } } @@ -165,7 +165,7 @@ export default class PipelineStore { this.closePipeline(pipeline); if (pipeline.triggered && pipeline.triggered.length) { - pipeline.triggered.forEach(triggered => this.closeTriggeredPipeline(triggered)); + pipeline.triggered.forEach((triggered) => this.closeTriggeredPipeline(triggered)); } } @@ -198,6 +198,9 @@ export default class PipelineStore { } removeExpandedPipelineToRequestData(id) { - this.state.expandedPipelines.splice(this.state.expandedPipelines.findIndex(el => el === id), 1); + this.state.expandedPipelines.splice( + this.state.expandedPipelines.findIndex((el) => el === id), + 1, + ); } } diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index 56f769c00fa..c31e7dd114f 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -1,18 +1,18 @@ import { addIconStatus, formattedTime } from './utils'; -export const getTestSuites = state => { +export const getTestSuites = (state) => { const { test_suites: testSuites = [] } = state.testReports; - return testSuites.map(suite => ({ + return testSuites.map((suite) => ({ ...suite, formattedTime: formattedTime(suite.total_time), })); }; -export const getSelectedSuite = state => +export const getSelectedSuite = (state) => state.testReports?.test_suites?.[state.selectedSuiteIndex] || {}; -export const getSuiteTests = state => { +export const getSuiteTests = (state) => { const { test_cases: testCases = [] } = getSelectedSuite(state); const { page, perPage } = state.pageInfo; const start = (page - 1) * perPage; @@ -20,4 +20,4 @@ export const getSuiteTests = state => { return testCases.map(addIconStatus).slice(start, start + perPage); }; -export const getSuiteTestCount = state => getSelectedSuite(state)?.test_cases?.length || 0; +export const getSuiteTestCount = (state) => getSelectedSuite(state)?.test_cases?.length || 0; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/index.js b/app/assets/javascripts/pipelines/stores/test_reports/index.js index 88f61b09025..204dfc2fb01 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/index.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/index.js @@ -7,7 +7,7 @@ import mutations from './mutations'; Vue.use(Vuex); -export default initialState => +export default (initialState) => new Vuex.Store({ actions, getters, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/utils.js b/app/assets/javascripts/pipelines/stores/test_reports/utils.js index 42406e5a67a..5c1f27b166a 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/utils.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/utils.js @@ -25,7 +25,7 @@ export const formattedTime = (seconds = 0) => { return sprintf(__('%{seconds}s'), { seconds: seconds.toFixed(2) }); }; -export const addIconStatus = testCase => ({ +export const addIconStatus = (testCase) => ({ ...testCase, icon: iconForTestStatus(testCase.status), formattedTime: formattedTime(testCase.execution_time), diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 28d6c0edb0f..50bb23b7e63 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -1,12 +1,11 @@ import { pickBy } from 'lodash'; import { SUPPORTED_FILTER_PARAMETERS } from './constants'; +import { createNodeDict } from './components/parsing_utils'; -export const validateParams = params => { +export const validateParams = (params) => { return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); }; -export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`; - /** * This function takes the stages array and transform it * into a hash where each key is a job name and the job data @@ -15,19 +14,8 @@ export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobNam * @returns {Object} - Hash of jobs */ export const createJobsHash = (stages = []) => { - const jobsHash = {}; - - stages.forEach(stage => { - if (stage.groups.length > 0) { - stage.groups.forEach(group => { - group.jobs.forEach(job => { - jobsHash[job.name] = job; - }); - }); - } - }); - - return jobsHash; + const nodes = stages.flatMap(({ groups }) => groups); + return createNodeDict(nodes); }; /** @@ -44,18 +32,26 @@ export const generateJobNeedsDict = (jobs = {}) => { const arrOfJobNames = Object.keys(jobs); return arrOfJobNames.reduce((acc, value) => { - const recursiveNeeds = jobName => { + const recursiveNeeds = (jobName) => { if (!jobs[jobName]?.needs) { return []; } return jobs[jobName].needs - .map(job => { + .map((job) => { // If we already have the needs of a job in the accumulator, // then we use the memoized data instead of the recursive call // to save some performance. const newNeeds = acc[job] ?? recursiveNeeds(job); + // In case it's a parallel job (size > 1), the name of the group + // and the job will be different. This mean we also need to add the group name + // to the list of `needs` to ensure we can properly reference it. + const group = jobs[job]; + if (group.size > 1) { + return [job, group.name, ...newNeeds]; + } + return [job, ...newNeeds]; }) .flat(Infinity); |