diff options
Diffstat (limited to 'app/assets/javascripts/pipelines')
26 files changed, 450 insertions, 1116 deletions
diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index ea45b5e3ec7..015f0519c72 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -39,10 +39,10 @@ export default { required: false, default: false, }, - pipelineLayers: { - type: Array, + computedPipelineInfo: { + type: Object, required: false, - default: () => [], + default: () => ({}), }, type: { type: String, @@ -81,7 +81,10 @@ export default { layout() { return this.isStageView ? this.pipeline.stages - : generateColumnsFromLayersListMemoized(this.pipeline, this.pipelineLayers); + : generateColumnsFromLayersListMemoized( + this.pipeline, + this.computedPipelineInfo.pipelineLayers, + ); }, hasDownstreamPipelines() { return Boolean(this.pipeline?.downstream?.length > 0); @@ -92,6 +95,9 @@ export default { isStageView() { return this.viewType === STAGE_VIEW; }, + linksData() { + return this.computedPipelineInfo?.linksData ?? null; + }, metricsConfig() { return { path: this.configPaths.metricsPath, @@ -188,6 +194,7 @@ export default { :container-id="containerId" :container-measurements="measurements" :highlighted-job="hoveredJobName" + :links-data="linksData" :metrics-config="metricsConfig" :show-links="showJobLinks" :view-type="viewType" diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue deleted file mode 100644 index 39d0fa8a8ca..00000000000 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_legacy.vue +++ /dev/null @@ -1,269 +0,0 @@ -<script> -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'; - -export default { - name: 'PipelineGraphLegacy', - components: { - GlLoadingIcon, - LinkedPipelinesColumnLegacy, - StageColumnComponentLegacy, - }, - mixins: [GraphBundleMixin], - props: { - isLoading: { - type: Boolean, - required: true, - }, - pipeline: { - type: Object, - required: true, - }, - isLinkedPipeline: { - type: Boolean, - required: false, - default: false, - }, - mediator: { - type: Object, - required: true, - }, - type: { - type: String, - required: false, - default: MAIN, - }, - }, - upstream: UPSTREAM, - downstream: DOWNSTREAM, - data() { - return { - downstreamMarginTop: null, - jobName: null, - pipelineExpanded: { - jobName: '', - expanded: false, - }, - }; - }, - computed: { - graph() { - return this.pipeline.details?.stages; - }, - hasUpstream() { - return ( - this.type !== this.$options.downstream && - this.upstreamPipelines && - this.pipeline.triggered_by !== null - ); - }, - upstreamPipelines() { - return this.pipeline.triggered_by; - }, - hasDownstream() { - return ( - this.type !== this.$options.upstream && - this.downstreamPipelines && - this.pipeline.triggered.length > 0 - ); - }, - downstreamPipelines() { - return this.pipeline.triggered; - }, - expandedUpstream() { - return ( - this.pipeline.triggered_by && - Array.isArray(this.pipeline.triggered_by) && - this.pipeline.triggered_by.find((el) => el.isExpanded) - ); - }, - expandedDownstream() { - return this.pipeline.triggered && this.pipeline.triggered.find((el) => el.isExpanded); - }, - pipelineTypeUpstream() { - return this.type !== this.$options.downstream && this.expandedUpstream; - }, - pipelineTypeDownstream() { - return this.type !== this.$options.upstream && this.expandedDownstream; - }, - pipelineProjectId() { - return this.pipeline.project.id; - }, - }, - errorCaptured(err, _vm, info) { - reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); - }, - methods: { - capitalizeStageName(name) { - const escapedName = escape(name); - return capitalize(escapedName); - }, - isFirstColumn(index) { - return index === 0; - }, - stageConnectorClass(index, stage) { - let className; - - // If it's the first stage column and only has one job - if (this.isFirstColumn(index) && stage.groups.length === 1) { - className = 'no-margin'; - } else if (index > 0) { - // If it is not the first column - className = 'left-margin'; - } - - return className; - }, - refreshPipelineGraph() { - this.$emit('refreshPipelineGraph'); - }, - /** - * CSS class is applied: - * - if pipeline graph contains only one stage column component - * - * @param {number} index - * @returns {boolean} - */ - shouldAddRightMargin(index) { - return !(index === this.graph.length - 1); - }, - handleClickedDownstream(pipeline, clickedIndex, downstreamNode) { - /** - * Calculates the margin top of the clicked downstream pipeline by - * subtracting the clicked downstream pipelines offsetTop by it's parent's - * offsetTop and then subtracting 15 - */ - this.downstreamMarginTop = this.calculateMarginTop(downstreamNode, 15); - - /** - * If the expanded trigger is defined and the id is different than the - * pipeline we clicked, then it means we clicked on a sibling downstream link - * and we want to reset the pipeline store. Triggering the reset without - * this condition would mean not allowing downstreams of downstreams to expand - */ - if (this.expandedDownstream?.id !== pipeline.id) { - this.$emit('onResetDownstream', this.pipeline, pipeline); - } - - this.$emit('onClickDownstreamPipeline', pipeline); - }, - calculateMarginTop(downstreamNode, pixelDiff) { - return `${downstreamNode.offsetTop - downstreamNode.offsetParent.offsetTop - pixelDiff}px`; - }, - hasOnlyOneJob(stage) { - return stage.groups.length === 1; - }, - hasUpstreamColumn(index) { - return index === 0 && this.hasUpstream; - }, - setJob(jobName) { - this.jobName = jobName; - }, - setPipelineExpanded(jobName, expanded) { - if (expanded) { - this.pipelineExpanded = { - jobName, - expanded, - }; - } else { - this.pipelineExpanded = { - expanded, - jobName: '', - }; - } - }, - }, -}; -</script> -<template> - <div class="build-content middle-block js-pipeline-graph"> - <div - class="pipeline-visualization pipeline-graph" - :class="{ 'pipeline-tab-content': !isLinkedPipeline }" - > - <div class="gl-w-full"> - <div class="container-fluid container-limited"> - <gl-loading-icon v-if="isLoading" class="m-auto" size="lg" /> - <pipeline-graph-legacy - v-if="pipelineTypeUpstream" - :type="$options.upstream" - class="d-inline-block upstream-pipeline" - :class="`js-upstream-pipeline-${expandedUpstream.id}`" - :is-loading="false" - :pipeline="expandedUpstream" - :is-linked-pipeline="true" - :mediator="mediator" - @onClickUpstreamPipeline="clickUpstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> - - <linked-pipelines-column-legacy - v-if="hasUpstream" - :type="$options.upstream" - :linked-pipelines="upstreamPipelines" - :column-title="__('Upstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)" - /> - - <ul - v-if="!isLoading" - :class="{ - 'inline js-has-linked-pipelines': hasDownstream || hasUpstream, - }" - class="stage-column-list align-top" - > - <stage-column-component-legacy - v-for="(stage, index) in graph" - :key="stage.name" - :class="{ - 'has-upstream gl-ml-11': hasUpstreamColumn(index), - 'has-only-one-job': hasOnlyOneJob(stage), - 'gl-mr-26': shouldAddRightMargin(index), - }" - :title="capitalizeStageName(stage.name)" - :groups="stage.groups" - :stage-connector-class="stageConnectorClass(index, stage)" - :is-first-column="isFirstColumn(index)" - :has-upstream="hasUpstream" - :action="stage.status.action" - :job-hovered="jobName" - :pipeline-expanded="pipelineExpanded" - @refreshPipelineGraph="refreshPipelineGraph" - /> - </ul> - - <linked-pipelines-column-legacy - v-if="hasDownstream" - :type="$options.downstream" - :linked-pipelines="downstreamPipelines" - :column-title="__('Downstream')" - :project-id="pipelineProjectId" - @linkedPipelineClick="handleClickedDownstream" - @downstreamHovered="setJob" - @pipelineExpandToggle="setPipelineExpanded" - /> - - <pipeline-graph-legacy - v-if="pipelineTypeDownstream" - :type="$options.downstream" - class="d-inline-block" - :class="`js-downstream-pipeline-${expandedDownstream.id}`" - :is-loading="false" - :pipeline="expandedDownstream" - :is-linked-pipeline="true" - :style="{ 'margin-top': downstreamMarginTop }" - :mediator="mediator" - @onClickDownstreamPipeline="clickDownstreamPipeline" - @refreshPipelineGraph="requestRefreshPipelineGraph" - /> - </div> - </div> - </div> - </div> -</template> 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 a948a57c144..e995d400907 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -4,15 +4,15 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.qu import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.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 DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; +import getPipelineQuery from '../../graphql/queries/get_pipeline_header_data.query.graphql'; import { reportToSentry, reportMessageToSentry } 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 { + calculatePipelineLayersInfo, getQueryHeaders, serializeLoadErrors, toggleQueryPollingByVisibility, @@ -31,7 +31,6 @@ export default { LocalStorageSync, PipelineGraph, }, - mixins: [glFeatureFlagMixin()], inject: { graphqlResourceEtag: { default: '', @@ -50,9 +49,10 @@ export default { return { alertType: null, callouts: [], + computedPipelineInfo: null, currentViewType: STAGE_VIEW, + canRefetchHeaderPipeline: false, pipeline: null, - pipelineLayers: null, showAlert: false, showLinks: false, }; @@ -78,6 +78,26 @@ export default { ); }, }, + headerPipeline: { + query: getPipelineQuery, + // this query is already being called in header_component.vue, which shares the same cache as this component + // the skip here is to prevent sending double network requests on page load + skip() { + return !this.canRefetchHeaderPipeline; + }, + variables() { + return { + fullPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + return data.project?.pipeline || {}; + }, + error() { + this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); + }, + }, pipeline: { context() { return getQueryHeaders(this.graphqlResourceEtag); @@ -178,7 +198,7 @@ export default { return this.$apollo.queries.pipeline.loading && !this.pipeline; }, showGraphViewSelector() { - return Boolean(this.glFeatures.pipelineGraphLayersView && this.pipeline?.usesNeeds); + return this.pipeline?.usesNeeds; }, }, mounted() { @@ -192,12 +212,16 @@ export default { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, methods: { - getPipelineLayers() { - if (this.currentViewType === LAYER_VIEW && !this.pipelineLayers) { - this.pipelineLayers = listByLayers(this.pipeline); + getPipelineInfo() { + if (this.currentViewType === LAYER_VIEW && !this.computedPipelineInfo) { + this.computedPipelineInfo = calculatePipelineLayersInfo( + this.pipeline, + this.$options.name, + this.metricsPath, + ); } - return this.pipelineLayers; + return this.computedPipelineInfo; }, handleTipDismissal() { try { @@ -217,6 +241,10 @@ export default { }, refreshPipelineGraph() { this.$apollo.queries.pipeline.refetch(); + + // this will update the status in header_component since they share the same cache + this.canRefetchHeaderPipeline = true; + this.$apollo.queries.headerPipeline.refetch(); }, /* eslint-disable @gitlab/require-i18n-strings */ reportFailure({ type, err = 'No error string passed.', skipSentry = false }) { @@ -262,7 +290,7 @@ export default { v-if="pipeline" :config-paths="configPaths" :pipeline="pipeline" - :pipeline-layers="getPipelineLayers()" + :computed-pipeline-info="getPipelineInfo()" :show-links="showLinks" :view-type="graphViewType" @error="reportFailure" 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 52ee40bd982..d251e0d8bd8 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -2,10 +2,10 @@ import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { LOAD_FAILURE } from '../../constants'; import { reportToSentry } from '../../utils'; -import { listByLayers } from '../parsing_utils'; import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from './constants'; import LinkedPipeline from './linked_pipeline.vue'; import { + calculatePipelineLayersInfo, getQueryHeaders, serializeLoadErrors, toggleQueryPollingByVisibility, @@ -138,7 +138,11 @@ export default { }, getPipelineLayers(id) { if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) { - this.pipelineLayers[id] = listByLayers(this.currentPipeline); + this.pipelineLayers[id] = calculatePipelineLayersInfo( + this.currentPipeline, + this.$options.name, + this.configPaths.metricsPath, + ); } return this.pipelineLayers[id]; @@ -223,7 +227,7 @@ export default { class="d-inline-block gl-mt-n2" :config-paths="configPaths" :pipeline="currentPipeline" - :pipeline-layers="getPipelineLayers(pipeline.id)" + :computed-pipeline-info="getPipelineLayers(pipeline.id)" :show-links="showLinks" :is-linked-pipeline="true" :view-type="graphViewType" 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 deleted file mode 100644 index 39baeb6e1c3..00000000000 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column_legacy.vue +++ /dev/null @@ -1,91 +0,0 @@ -<script> -import { reportToSentry } from '../../utils'; -import { UPSTREAM } from './constants'; -import LinkedPipeline from './linked_pipeline.vue'; - -export default { - components: { - LinkedPipeline, - }, - props: { - columnTitle: { - type: String, - required: true, - }, - linkedPipelines: { - type: Array, - required: true, - }, - type: { - type: String, - required: true, - }, - projectId: { - type: Number, - required: true, - }, - }, - computed: { - columnClass() { - const positionValues = { - right: 'gl-ml-11', - left: 'gl-mr-7', - }; - return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; - }, - graphPosition() { - return this.isUpstream ? 'left' : 'right'; - }, - isExpanded() { - return this.pipeline?.isExpanded || false; - }, - isUpstream() { - 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); - }, - onDownstreamHovered(jobName) { - this.$emit('downstreamHovered', jobName); - }, - onPipelineExpandToggle(jobName, expanded) { - // Highlighting only applies to downstream pipelines - if (this.isUpstream) { - return; - } - - this.$emit('pipelineExpandToggle', jobName, expanded); - }, - }, -}; -</script> - -<template> - <div :class="columnClass" class="stage-column linked-pipelines-column"> - <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> - <div v-if="isUpstream" class="cross-project-triangle"></div> - <ul> - <li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id"> - <linked-pipeline - :class="{ - active: pipeline.isExpanded, - 'left-connector': pipeline.isExpanded && graphPosition === 'left', - }" - :pipeline="pipeline" - :column-title="columnTitle" - :project-id="projectId" - :type="type" - :expanded="isExpanded" - @pipelineClicked="onPipelineClick($event, pipeline, index)" - @downstreamHovered="onDownstreamHovered" - @pipelineExpandToggle="onPipelineExpandToggle" - /> - </li> - </ul> - </div> -</template> diff --git a/app/assets/javascripts/pipelines/components/graph/perf_utils.js b/app/assets/javascripts/pipelines/components/graph/perf_utils.js new file mode 100644 index 00000000000..3737a209f5c --- /dev/null +++ b/app/assets/javascripts/pipelines/components/graph/perf_utils.js @@ -0,0 +1,50 @@ +import { + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; + +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { reportPerformance } from '../graph_shared/api'; + +export const beginPerfMeasure = () => { + performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); +}; + +export const finishPerfMeasureAndSend = (numLinks, numGroups, metricsPath) => { + performanceMarkAndMeasure({ + mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + measures: [ + { + name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + }, + ], + }); + + window.requestAnimationFrame(() => { + const duration = window.performance.getEntriesByName( + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + )[0]?.duration; + + if (!duration) { + return; + } + + const data = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / numGroups, + }, + ], + }; + + reportPerformance(metricsPath, data); + }); +}; 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 deleted file mode 100644 index cbaf07c05cf..00000000000 --- a/app/assets/javascripts/pipelines/components/graph/stage_column_component_legacy.vue +++ /dev/null @@ -1,112 +0,0 @@ -<script> -import { isEmpty, escape } from 'lodash'; -import stageColumnMixin from '../../mixins/stage_column_mixin'; -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'; - -export default { - components: { - JobItem, - JobGroupDropdown, - ActionComponent, - }, - mixins: [stageColumnMixin], - props: { - title: { - type: String, - required: true, - }, - groups: { - type: Array, - required: true, - }, - isFirstColumn: { - type: Boolean, - required: false, - default: false, - }, - stageConnectorClass: { - type: String, - required: false, - default: '', - }, - action: { - type: Object, - required: false, - default: () => ({}), - }, - jobHovered: { - type: String, - required: false, - default: '', - }, - pipelineExpanded: { - type: Object, - required: false, - default: () => ({}), - }, - }, - computed: { - hasAction() { - 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)}`; - }, - pipelineActionRequestComplete() { - this.$emit('refreshPipelineGraph'); - }, - }, -}; -</script> -<template> - <li :class="stageConnectorClass" class="stage-column"> - <div class="stage-name position-relative" data-testid="stage-column-title"> - {{ title }} - <action-component - v-if="hasAction" - :action-icon="action.icon" - :tooltip-text="action.title" - :link="action.path" - class="js-stage-action stage-action rounded" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </div> - - <div class="builds-container"> - <ul> - <li - v-for="(group, index) in groups" - :id="groupId(group)" - :key="group.id" - :class="buildConnnectorClass(index)" - class="build" - > - <div class="curve"></div> - - <job-item - v-if="group.size === 1" - :job="group.jobs[0]" - :job-hovered="jobHovered" - :pipeline-expanded="pipelineExpanded" - css-class-job-name="build-content" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - - <job-group-dropdown - v-if="group.size > 1" - :group="group" - @pipelineActionRequestComplete="pipelineActionRequestComplete" - /> - </li> - </ul> - </div> - </li> -</template> diff --git a/app/assets/javascripts/pipelines/components/graph/utils.js b/app/assets/javascripts/pipelines/components/graph/utils.js index 163b3898c28..3da792cb9df 100644 --- a/app/assets/javascripts/pipelines/components/graph/utils.js +++ b/app/assets/javascripts/pipelines/components/graph/utils.js @@ -1,7 +1,10 @@ import { isEmpty } from 'lodash'; import Visibility from 'visibilityjs'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { reportToSentry } from '../../utils'; +import { listByLayers } from '../parsing_utils'; import { unwrapStagesWithNeedsAndLookup } from '../unwrapping_utils'; +import { beginPerfMeasure, finishPerfMeasureAndSend } from './perf_utils'; const addMulti = (mainPipelineProjectPath, linkedPipeline) => { return { @@ -10,6 +13,28 @@ const addMulti = (mainPipelineProjectPath, linkedPipeline) => { }; }; +const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => { + const shouldCollectMetrics = Boolean(metricsPath); + + if (shouldCollectMetrics) { + beginPerfMeasure(); + } + + let layers = null; + + try { + layers = listByLayers(pipeline); + + if (shouldCollectMetrics) { + finishPerfMeasureAndSend(layers.linksData.length, layers.numGroups, metricsPath); + } + } catch (err) { + reportToSentry(componentName, err); + } + + return layers; +}; + /* eslint-disable @gitlab/require-i18n-strings */ const getQueryHeaders = (etagResource) => { return { @@ -106,6 +131,7 @@ const unwrapPipelineData = (mainPipelineProjectPath, data) => { const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0; export { + calculatePipelineLayersInfo, getQueryHeaders, serializeGqlErr, serializeLoadErrors, diff --git a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js index 83f2466f0bf..d6d9ea94c13 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js +++ b/app/assets/javascripts/pipelines/components/graph_shared/drawing_utils.js @@ -13,7 +13,7 @@ export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobNam * @returns {Array} Links that contain all the information about them */ -export const generateLinksData = ({ links }, containerID, modifier = '') => { +export const generateLinksData = (links, containerID, modifier = '') => { const containerEl = document.getElementById(containerID); return links.map((link) => { 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 5c775df7b48..1189c2ebad8 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_inner.vue @@ -17,8 +17,8 @@ export default { type: Object, required: true, }, - parsedData: { - type: Object, + linksData: { + type: Array, required: true, }, pipelineId: { @@ -95,7 +95,7 @@ export default { highlightedJobs(jobs) { this.$emit('highlightedJobsChange', jobs); }, - parsedData() { + linksData() { this.calculateLinkData(); }, viewType() { @@ -112,7 +112,7 @@ export default { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, mounted() { - if (!isEmpty(this.parsedData)) { + if (!isEmpty(this.linksData)) { this.calculateLinkData(); } }, @@ -122,7 +122,7 @@ export default { }, calculateLinkData() { try { - this.links = generateLinksData(this.parsedData, this.containerId, `-${this.pipelineId}`); + this.links = generateLinksData(this.linksData, this.containerId, `-${this.pipelineId}`); } catch (err) { this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false }); reportToSentry(this.$options.name, err); 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 81409752621..ef24694e494 100644 --- a/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue +++ b/app/assets/javascripts/pipelines/components/graph_shared/links_layer.vue @@ -1,20 +1,16 @@ <script> -import { isEmpty } from 'lodash'; -import { __ } from '~/locale'; -import { - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - PIPELINES_DETAIL_LINK_DURATION, - PIPELINES_DETAIL_LINKS_TOTAL, - PIPELINES_DETAIL_LINKS_JOB_RATIO, -} from '~/performance/constants'; -import { performanceMarkAndMeasure } from '~/performance/utils'; +import { memoize } from 'lodash'; import { reportToSentry } from '../../utils'; import { parseData } from '../parsing_utils'; -import { reportPerformance } from './api'; import LinksInner from './links_inner.vue'; +const parseForLinksBare = (pipeline) => { + const arrayOfJobs = pipeline.flatMap(({ groups }) => groups); + return parseData(arrayOfJobs).links; +}; + +const parseForLinks = memoize(parseForLinksBare); + export default { name: 'LinksLayer', components: { @@ -29,10 +25,10 @@ export default { type: Array, required: true, }, - metricsConfig: { - type: Object, + linksData: { + type: Array, required: false, - default: () => ({}), + default: () => [], }, showLinks: { type: Boolean, @@ -40,30 +36,16 @@ export default { default: true, }, }, - data() { - return { - alertDismissed: false, - parsedData: {}, - 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); - }, - shouldCollectMetrics() { - return this.metricsConfig.collectMetrics && this.metricsConfig.path; + getLinksData() { + if (this.linksData.length > 0) { + return this.linksData; + } + + return parseForLinks(this.pipelineData); }, showLinkedLayers() { return this.showLinks && !this.containerZero; @@ -72,77 +54,14 @@ export default { errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); }, - mounted() { - if (!isEmpty(this.pipelineData)) { - window.requestAnimationFrame(() => { - this.prepareLinkData(); - }); - } - }, - methods: { - beginPerfMeasure() { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); - } - }, - finishPerfMeasureAndSend(numLinks) { - if (this.shouldCollectMetrics) { - performanceMarkAndMeasure({ - mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, - measures: [ - { - name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, - }, - ], - }); - } - - window.requestAnimationFrame(() => { - const duration = window.performance.getEntriesByName( - PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, - )[0]?.duration; - - if (!duration) { - return; - } - - const data = { - histograms: [ - { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, - { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, - { - name: PIPELINES_DETAIL_LINKS_JOB_RATIO, - value: numLinks / this.numGroups, - }, - ], - }; - - reportPerformance(this.metricsConfig.path, data); - }); - }, - prepareLinkData() { - this.beginPerfMeasure(); - let numLinks; - try { - const arrayOfJobs = this.pipelineData.flatMap(({ groups }) => groups); - this.parsedData = parseData(arrayOfJobs); - numLinks = this.parsedData.links.length; - } catch (err) { - reportToSentry(this.$options.name, err); - } - this.finishPerfMeasureAndSend(numLinks); - }, - }, }; </script> <template> <links-inner v-if="showLinkedLayers" :container-measurements="containerMeasurements" - :parsed-data="parsedData" + :links-data="getLinksData" :pipeline-data="pipelineData" - :total-groups="numGroups" v-bind="$attrs" v-on="$listeners" > diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index b7500ef00b0..5db2b604956 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -143,13 +143,6 @@ export default { return cancelable && userPermissions.updatePipeline; }, }, - watch: { - isFinished(finished) { - if (finished) { - this.$apollo.queries.pipeline.stopPolling(); - } - }, - }, methods: { reportFailure(errorType) { this.failureType = errorType; @@ -218,7 +211,7 @@ export default { }; </script> <template> - <div class="pipeline-header-container"> + <div class="js-pipeline-header-container"> <gl-alert v-if="hasError" :variant="failure.variant">{{ failure.text }}</gl-alert> <ci-header v-if="shouldRenderContent" diff --git a/app/assets/javascripts/pipelines/components/parsing_utils.js b/app/assets/javascripts/pipelines/components/parsing_utils.js index b36c9c0d049..7e7f0572faf 100644 --- a/app/assets/javascripts/pipelines/components/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/parsing_utils.js @@ -175,7 +175,7 @@ export const listByLayers = ({ stages }) => { const parsedData = parseData(arrayOfJobs); const dataWithLayers = createSankey()(parsedData); - return dataWithLayers.nodes.reduce((acc, { layer, name }) => { + const pipelineLayers = dataWithLayers.nodes.reduce((acc, { layer, name }) => { /* sort groups by layer */ if (!acc[layer]) { @@ -186,6 +186,12 @@ export const listByLayers = ({ stages }) => { return acc; }, []); + + return { + linksData: parsedData.links, + numGroups: arrayOfJobs.length, + pipelineLayers, + }; }; export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipelineLayers) => { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue index 5e18f636b52..40ee071f1f5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_multi_actions.vue @@ -16,6 +16,7 @@ export const i18n = { downloadArtifact: __('Download %{name} artifact'), artifactSectionHeader: __('Download artifacts'), artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), + emptyArtifactsMessage: __('No artifacts found'), }; export default { @@ -99,6 +100,10 @@ export default { <gl-loading-icon v-if="isLoading" size="sm" /> + <gl-dropdown-item v-if="!artifacts.length" data-testid="artifacts-empty-message"> + {{ $options.i18n.emptyArtifactsMessage }} + </gl-dropdown-item> + <gl-dropdown-item v-for="(artifact, i) in artifacts" :key="i" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue index 85ee44f427d..b6c178d20b0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_operations.vue @@ -95,10 +95,10 @@ export default { :title="$options.i18n.cancelTitle" :loading="isCancelling" :disabled="isCancelling" - icon="close" + icon="cancel" variant="danger" category="primary" - class="js-pipelines-cancel-button" + class="js-pipelines-cancel-button gl-ml-1" @click="handleCancelClick" /> 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 fc8f31c5b7e..e2f30d5a8e6 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -29,6 +29,10 @@ export default { type: String, required: true, }, + pipelineKey: { + type: String, + required: true, + }, }, computed: { user() { @@ -60,7 +64,7 @@ export default { data-testid="pipeline-url-link" data-qa-selector="pipeline_url_link" > - #{{ pipeline.id }} + #{{ pipeline[pipelineKey] }} </gl-link> <div class="label-container"> <gl-badge diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index e3373178239..e7ff5449331 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,12 +1,17 @@ <script> -import { GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlEmptyState, GlIcon, GlLoadingIcon } from '@gitlab/ui'; import { isEqual } from 'lodash'; import createFlash from '~/flash'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; -import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../../constants'; +import { + ANY_TRIGGER_AUTHOR, + RAW_TEXT_WARNING, + FILTER_TAG_IDENTIFIER, + PipelineKeyOptions, +} from '../../constants'; import PipelinesMixin from '../../mixins/pipelines_mixin'; import PipelinesService from '../../services/pipelines_service'; import { validateParams } from '../../utils'; @@ -16,8 +21,11 @@ import PipelinesFilteredSearch from './pipelines_filtered_search.vue'; import PipelinesTableComponent from './pipelines_table.vue'; export default { + PipelineKeyOptions, components: { EmptyState, + GlDropdown, + GlDropdownItem, GlEmptyState, GlIcon, GlLoadingIcon, @@ -114,6 +122,7 @@ export default { page: getParameterByName('page') || '1', requestData: {}, isResetCacheButtonLoading: false, + selectedPipelineKeyOption: this.$options.PipelineKeyOptions[0], }; }, stateMap: { @@ -301,6 +310,9 @@ export default { this.updateContent(this.requestData); }, + changeVisibilityPipelineID(val) { + this.selectedPipelineKeyOption = val; + }, }, }; </script> @@ -330,12 +342,31 @@ export default { /> </div> - <pipelines-filtered-search - v-if="stateToRender !== $options.stateMap.emptyState" - :project-id="projectId" - :params="validatedParams" - @filterPipelines="filterPipelines" - /> + <div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex"> + <div class="row-content-block gl-display-flex gl-flex-grow-1"> + <pipelines-filtered-search + class="gl-display-flex gl-flex-grow-1 gl-mr-4" + :project-id="projectId" + :params="validatedParams" + @filterPipelines="filterPipelines" + /> + <gl-dropdown + class="gl-display-flex" + :text="selectedPipelineKeyOption.text" + data-testid="pipeline-key-dropdown" + > + <gl-dropdown-item + v-for="(val, index) in $options.PipelineKeyOptions" + :key="index" + :is-checked="selectedPipelineKeyOption.key === val.key" + is-check-item + @click="changeVisibilityPipelineID(val)" + > + {{ val.text }} + </gl-dropdown-item> + </gl-dropdown> + </div> + </div> <div class="content-list pipelines"> <gl-loading-icon @@ -374,6 +405,7 @@ export default { :pipeline-schedule-url="pipelineScheduleUrl" :update-graph-dropdown="updateGraphDropdown" :view-type="viewType" + :pipeline-key-option="selectedPipelineKeyOption" /> </div> 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 de3f783ac84..0b70e74b8ff 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 @@ -4,6 +4,7 @@ import { map } from 'lodash'; import { s__ } from '~/locale'; import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; +import PipelineSourceToken from './tokens/pipeline_source_token.vue'; import PipelineStatusToken from './tokens/pipeline_status_token.vue'; import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue'; import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue'; @@ -13,6 +14,7 @@ export default { branchType: 'ref', tagType: 'tag', statusType: 'status', + sourceType: 'source', defaultTokensLength: 1, components: { GlFilteredSearch, @@ -37,7 +39,7 @@ export default { return this.value.map((i) => i.type); }, tokens() { - return [ + const tokens = [ { type: this.$options.userType, icon: 'user', @@ -76,6 +78,19 @@ export default { operators: OPERATOR_IS_ONLY, }, ]; + + if (gon.features.pipelineSourceFilter) { + tokens.push({ + type: this.$options.sourceType, + icon: 'trigger-source', + title: s__('Pipeline|Source'), + unique: true, + token: PipelineSourceToken, + operators: OPERATOR_IS_ONLY, + }); + } + + return tokens; }, parsedParams() { return map(this.params, (val, key) => ({ @@ -101,12 +116,10 @@ export default { </script> <template> - <div class="row-content-block"> - <gl-filtered-search - v-model="value" - :placeholder="__('Filter pipelines')" - :available-tokens="tokens" - @submit="onSubmit" - /> - </div> + <gl-filtered-search + v-model="value" + :placeholder="__('Filter pipelines')" + :available-tokens="tokens" + @submit="onSubmit" + /> </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 47fc7023222..2475d958e3c 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -17,65 +17,10 @@ const DEFAULT_TH_CLASSES = 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1! gl-font-sm!'; export default { - fields: [ - { - key: 'status', - label: s__('Pipeline|Status'), - thClass: DEFAULT_TH_CLASSES, - columnClass: 'gl-w-10p', - tdClass: DEFAULT_TD_CLASS, - thAttr: { 'data-testid': 'status-th' }, - }, - { - key: 'pipeline', - label: s__('Pipeline|Pipeline'), - thClass: DEFAULT_TH_CLASSES, - tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, - columnClass: 'gl-w-10p', - thAttr: { 'data-testid': 'pipeline-th' }, - }, - { - key: 'triggerer', - label: s__('Pipeline|Triggerer'), - thClass: DEFAULT_TH_CLASSES, - tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, - columnClass: 'gl-w-10p', - thAttr: { 'data-testid': 'triggerer-th' }, - }, - { - key: 'commit', - label: s__('Pipeline|Commit'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-20p', - thAttr: { 'data-testid': 'commit-th' }, - }, - { - key: 'stages', - label: s__('Pipeline|Stages'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-15p', - thAttr: { 'data-testid': 'stages-th' }, - }, - { - key: 'timeago', - label: s__('Pipeline|Duration'), - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-15p', - thAttr: { 'data-testid': 'timeago-th' }, - }, - { - key: 'actions', - thClass: DEFAULT_TH_CLASSES, - tdClass: DEFAULT_TD_CLASS, - columnClass: 'gl-w-20p', - thAttr: { 'data-testid': 'actions-th' }, - }, - ], components: { GlTable, + LinkedPipelinesMiniList: () => + import('ee_component/vue_shared/components/linked_pipelines_mini_list.vue'), PipelinesCommit, PipelineMiniGraph, PipelineOperations, @@ -107,6 +52,10 @@ export default { type: String, required: true, }, + pipelineKeyOption: { + type: Object, + required: true, + }, }, data() { return { @@ -116,6 +65,68 @@ export default { cancelingPipeline: null, }; }, + computed: { + tableFields() { + const fields = [ + { + key: 'status', + label: s__('Pipeline|Status'), + thClass: DEFAULT_TH_CLASSES, + columnClass: 'gl-w-10p', + tdClass: DEFAULT_TD_CLASS, + thAttr: { 'data-testid': 'status-th' }, + }, + { + key: 'pipeline', + label: this.pipelineKeyOption.label, + thClass: DEFAULT_TH_CLASSES, + tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-10p', + thAttr: { 'data-testid': 'pipeline-th' }, + }, + { + key: 'triggerer', + label: s__('Pipeline|Triggerer'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${DEFAULT_TD_CLASS} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-10p', + thAttr: { 'data-testid': 'triggerer-th' }, + }, + { + key: 'commit', + label: s__('Pipeline|Commit'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-20p', + thAttr: { 'data-testid': 'commit-th' }, + }, + { + key: 'stages', + label: s__('Pipeline|Stages'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-quarter', + thAttr: { 'data-testid': 'stages-th' }, + }, + { + key: 'timeago', + label: s__('Pipeline|Duration'), + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'timeago-th' }, + }, + { + key: 'actions', + thClass: DEFAULT_TH_CLASSES, + tdClass: DEFAULT_TD_CLASS, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'actions-th' }, + }, + ]; + return fields; + }, + }, watch: { pipelines() { this.cancelingPipeline = null; @@ -146,7 +157,7 @@ export default { <template> <div class="ci-table"> <gl-table - :fields="$options.fields" + :fields="tableFields" :items="pipelines" tbody-tr-class="commit" :tbody-tr-attr="{ 'data-testid': 'pipeline-table-row' }" @@ -167,7 +178,11 @@ export default { </template> <template #cell(pipeline)="{ item }"> - <pipeline-url :pipeline="item" :pipeline-schedule-url="pipelineScheduleUrl" /> + <pipeline-url + :pipeline="item" + :pipeline-schedule-url="pipelineScheduleUrl" + :pipeline-key="pipelineKeyOption.key" + /> </template> <template #cell(triggerer)="{ item }"> @@ -182,12 +197,23 @@ export default { <div class="stage-cell"> <!-- This empty div should be removed, see https://gitlab.com/gitlab-org/gitlab/-/issues/323488 --> <div></div> + <linked-pipelines-mini-list + v-if="item.triggered_by" + :triggered-by="[item.triggered_by]" + data-testid="mini-graph-upstream" + /> <pipeline-mini-graph v-if="item.details && item.details.stages && item.details.stages.length > 0" + class="gl-display-inline" :stages="item.details.stages" :update-dropdown="updateGraphDropdown" @pipelineActionRequestComplete="onPipelineActionRequestComplete" /> + <linked-pipelines-mini-list + v-if="item.triggered.length" + :triggered="item.triggered" + data-testid="mini-graph-downstream" + /> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue new file mode 100644 index 00000000000..71efa8b2ab4 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_source_token.vue @@ -0,0 +1,106 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + sources() { + return [ + { + text: s__('Pipeline|Source|Push'), + value: 'push', + }, + { + text: s__('Pipeline|Source|Web'), + value: 'web', + }, + { + text: s__('Pipeline|Source|Trigger'), + value: 'trigger', + }, + { + text: s__('Pipeline|Source|Schedule'), + value: 'schedule', + }, + { + text: s__('Pipeline|Source|API'), + value: 'api', + }, + { + text: s__('Pipeline|Source|External'), + value: 'external', + }, + { + text: s__('Pipeline|Source|Pipeline'), + value: 'pipeline', + }, + { + text: s__('Pipeline|Source|Chat'), + value: 'chat', + }, + { + text: s__('Pipeline|Source|Web IDE'), + value: 'webide', + }, + { + text: s__('Pipeline|Source|Merge Request'), + value: 'merge_request_event', + }, + { + text: s__('Pipeline|Source|External Pull Request'), + value: 'external_pull_request_event', + }, + { + text: s__('Pipeline|Source|Parent Pipeline'), + value: 'parent_pipeline', + }, + { + text: s__('Pipeline|Source|On-Demand DAST Scan'), + value: 'ondemand_dast_scan', + }, + { + text: s__('Pipeline|Source|On-Demand DAST Validation'), + value: 'ondemand_dast_validation', + }, + ]; + }, + findActiveSource() { + return this.sources.find((source) => source.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <span>{{ findActiveSource.text }}</span> + </div> + </template> + + <template #suggestions> + <gl-filtered-search-suggestion + v-for="source in sources" + :key="source.value" + :value="source.value" + > + {{ source.text }} + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js index 21b114825a6..5678b613ec6 100644 --- a/app/assets/javascripts/pipelines/constants.js +++ b/app/assets/javascripts/pipelines/constants.js @@ -4,7 +4,7 @@ export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const LAYOUT_CHANGE_DELAY = 300; export const FILTER_PIPELINES_SEARCH_DELAY = 200; export const ANY_TRIGGER_AUTHOR = 'Any'; -export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status']; +export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source']; export const FILTER_TAG_IDENTIFIER = 'tag'; export const SCHEDULE_ORIGIN = 'schedule'; @@ -35,3 +35,17 @@ export const POST_FAILURE = 'post_failure'; export const UNSUPPORTED_DATA = 'unsupported_data'; export const CHILD_VIEW = 'child'; + +// Constants for the ID and IID selection dropdown +export const PipelineKeyOptions = [ + { + text: __('Show Pipeline ID'), + label: __('Pipeline ID'), + key: 'id', + }, + { + text: __('Show Pipeline IID'), + label: __('Pipeline IID'), + key: 'iid', + }, +]; diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js deleted file mode 100644 index 5c34f4e4f7e..00000000000 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ /dev/null @@ -1,65 +0,0 @@ -import createFlash from '~/flash'; -import { __ } from '~/locale'; - -export default { - methods: { - getExpandedPipelines(pipeline) { - this.mediator.service - .getPipeline(this.mediator.getExpandedParameters()) - .then((response) => { - this.mediator.store.toggleLoading(pipeline); - this.mediator.store.storePipeline(response.data); - this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); - }) - .catch(() => { - this.mediator.store.toggleLoading(pipeline); - createFlash({ - message: __('An error occurred while fetching the pipeline.'), - }); - }); - }, - /** - * Called when a linked pipeline is clicked. - * - * If the pipeline is collapsed we will start polling it & we will reset the other pipelines. - * If the pipeline is expanded we will close it. - * - * @param {String} method Method to fetch the pipeline - * @param {String} storeKey Store property that will be updates - * @param {String} resetStoreKey Store key for the visible pipeline that will need to be reset - * @param {Object} pipeline The clicked pipeline - */ - clickPipeline(pipeline, openMethod, closeMethod) { - if (!pipeline.isExpanded) { - this.mediator.store[openMethod](pipeline); - this.mediator.store.toggleLoading(pipeline); - this.mediator.poll.stop(); - - this.getExpandedPipelines(pipeline); - } else { - this.mediator.store[closeMethod](pipeline); - this.mediator.poll.stop(); - - this.mediator.poll.enable({ data: this.mediator.getExpandedParameters() }); - } - }, - resetDownstreamPipelines(parentPipeline, pipeline) { - this.mediator.store.resetTriggeredPipelines(parentPipeline, pipeline); - }, - clickUpstreamPipeline(pipeline) { - this.clickPipeline(pipeline, 'openPipeline', 'closePipeline'); - }, - clickDownstreamPipeline(pipeline) { - this.clickPipeline(pipeline, 'openPipeline', 'closePipeline'); - }, - requestRefreshPipelineGraph() { - // When an action is clicked - // (whether in the dropdown or in the main nodes, we refresh the big graph) - this.mediator.refreshPipeline().catch(() => - createFlash({ - message: __('An error occurred while making the request.'), - }), - ); - }, - }, -}; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index e8d5ed175ba..c6e767d5424 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -3,15 +3,12 @@ import createFlash from '~/flash'; import { parseBoolean } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import Translate from '~/vue_shared/translate'; -import PipelineGraphLegacy from './components/graph/graph_component_legacy.vue'; 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 { apolloProvider } from './pipeline_shared_client'; import createTestReportsStore from './stores/test_reports'; -import { reportToSentry } from './utils'; Vue.use(Translate); @@ -22,44 +19,6 @@ const SELECTORS = { PIPELINE_TESTS: '#js-pipeline-tests-detail', }; -const createLegacyPipelinesDetailApp = (mediator) => { - if (!document.querySelector(SELECTORS.PIPELINE_GRAPH)) { - return; - } - // eslint-disable-next-line no-new - new Vue({ - el: SELECTORS.PIPELINE_GRAPH, - components: { - PipelineGraphLegacy, - }, - mixins: [GraphBundleMixin], - data() { - return { - mediator, - }; - }, - errorCaptured(err, _vm, info) { - reportToSentry('pipeline_details_bundle_legacy_details', `error: ${err}, info: ${info}`); - }, - render(createElement) { - return createElement('pipeline-graph-legacy', { - props: { - isLoading: this.mediator.state.isLoading, - pipeline: this.mediator.store.state.pipeline, - mediator: this.mediator, - }, - on: { - refreshPipelineGraph: this.requestRefreshPipelineGraph, - onResetDownstream: (parentPipeline, pipeline) => - this.resetDownstreamPipelines(parentPipeline, pipeline), - onClickUpstreamPipeline: (pipeline) => this.clickUpstreamPipeline(pipeline), - onClickDownstreamPipeline: (pipeline) => this.clickDownstreamPipeline(pipeline), - }, - }); - }, - }); -}; - const createTestDetails = () => { const el = document.querySelector(SELECTORS.PIPELINE_TESTS); const { blobPath, emptyStateImagePath, hasTestReport, summaryEndpoint, suiteEndpoint } = @@ -88,9 +47,6 @@ const createTestDetails = () => { }; export default async function initPipelineDetailsBundle() { - const canShowNewPipelineDetails = - gon.features.graphqlPipelineDetails || gon.features.graphqlPipelineDetailsUsers; - const { dataset } = document.querySelector(SELECTORS.PIPELINE_DETAILS); try { @@ -101,22 +57,12 @@ export default async function initPipelineDetailsBundle() { }); } - if (canShowNewPipelineDetails) { - try { - createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); - } catch { - createFlash({ - message: __('An error occurred while loading the pipeline.'), - }); - } - } else { - const { default: PipelinesMediator } = await import( - /* webpackChunkName: 'PipelinesMediator' */ './pipeline_details_mediator' - ); - const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); - mediator.fetchPipeline(); - - createLegacyPipelinesDetailApp(mediator); + try { + createPipelinesDetailApp(SELECTORS.PIPELINE_GRAPH, apolloProvider, dataset); + } catch { + createFlash({ + message: __('An error occurred while loading the pipeline.'), + }); } createDagApp(apolloProvider); diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js deleted file mode 100644 index 72c4fedc64c..00000000000 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ /dev/null @@ -1,81 +0,0 @@ -import Visibility from 'visibilityjs'; -import createFlash from '~/flash'; -import Poll from '../lib/utils/poll'; -import { __ } from '../locale'; -import PipelineService from './services/pipeline_service'; -import PipelineStore from './stores/pipeline_store'; - -export default class pipelinesMediator { - constructor(options = {}) { - this.options = options; - this.store = new PipelineStore(); - this.service = new PipelineService(options.endpoint); - - this.state = {}; - this.state.isLoading = false; - } - - fetchPipeline() { - this.poll = new Poll({ - resource: this.service, - method: 'getPipeline', - data: this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined, - successCallback: this.successCallback.bind(this), - errorCallback: this.errorCallback.bind(this), - }); - - if (!Visibility.hidden()) { - this.state.isLoading = true; - this.poll.makeRequest(); - } else { - this.refreshPipeline(); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.stopPipelinePoll(); - } - }); - } - - successCallback(response) { - this.state.isLoading = false; - this.store.storePipeline(response.data); - } - - errorCallback() { - this.state.isLoading = false; - createFlash({ - message: __('An error occurred while fetching the pipeline.'), - }); - } - - refreshPipeline() { - this.stopPipelinePoll(); - - return this.service - .getPipeline() - .then((response) => this.successCallback(response)) - .catch(() => this.errorCallback()) - .finally(() => - this.poll.restart( - this.store.state.expandedPipelines ? this.getExpandedParameters() : undefined, - ), - ); - } - - stopPipelinePoll() { - this.poll.stop(); - } - - /** - * Backend expects paramets in the following format: `expanded[]=id&expanded[]=id` - */ - getExpandedParameters() { - return { - expanded: this.store.state.expandedPipelines, - }; - } -} diff --git a/app/assets/javascripts/pipelines/services/pipeline_service.js b/app/assets/javascripts/pipelines/services/pipeline_service.js deleted file mode 100644 index ba2830ec596..00000000000 --- a/app/assets/javascripts/pipelines/services/pipeline_service.js +++ /dev/null @@ -1,21 +0,0 @@ -import axios from '../../lib/utils/axios_utils'; - -export default class PipelineService { - constructor(endpoint) { - this.pipeline = endpoint; - } - - getPipeline(params) { - return axios.get(this.pipeline, { params }); - } - - // eslint-disable-next-line class-methods-use-this - deleteAction(endpoint) { - return axios.delete(`${endpoint}.json`); - } - - // eslint-disable-next-line class-methods-use-this - postAction(endpoint) { - return axios.post(`${endpoint}.json`); - } -} diff --git a/app/assets/javascripts/pipelines/stores/pipeline_store.js b/app/assets/javascripts/pipelines/stores/pipeline_store.js deleted file mode 100644 index 1f804a107a8..00000000000 --- a/app/assets/javascripts/pipelines/stores/pipeline_store.js +++ /dev/null @@ -1,206 +0,0 @@ -import Vue from 'vue'; - -export default class PipelineStore { - constructor() { - this.state = {}; - this.state.pipeline = {}; - this.state.expandedPipelines = []; - } - /** - * For the triggered pipelines adds the `isExpanded` key - * - * For the triggered_by pipeline adds the `isExpanded` key - * and saves it as an array - * - * @param {Object} pipeline - */ - storePipeline(pipeline = {}) { - const pipelineCopy = { ...pipeline }; - - if (pipelineCopy.triggered_by) { - pipelineCopy.triggered_by = [pipelineCopy.triggered_by]; - - const oldTriggeredBy = - this.state.pipeline && - this.state.pipeline.triggered_by && - this.state.pipeline.triggered_by[0]; - - this.parseTriggeredByPipelines(oldTriggeredBy, pipelineCopy.triggered_by[0]); - } - - if (pipelineCopy.triggered && pipelineCopy.triggered.length) { - pipelineCopy.triggered.forEach((el) => { - const oldPipeline = - this.state.pipeline && - this.state.pipeline.triggered && - this.state.pipeline.triggered.find((element) => element.id === el.id); - - this.parseTriggeredPipelines(oldPipeline, el); - }); - } - - this.state.pipeline = pipelineCopy; - } - - /** - * Recursiverly parses the triggered by pipelines. - * - * Sets triggered_by as an array, there is always only 1 triggered_by pipeline. - * Adds key `isExpanding` - * Keeps old isExpading value due to polling - * - * @param {Array} parentPipeline - * @param {Object} pipeline - */ - parseTriggeredByPipelines(oldPipeline = {}, newPipeline) { - // keep old value in case it's opened because we're polling - Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false); - // add isLoading property - Vue.set(newPipeline, 'isLoading', false); - - // Because there can only ever be one `triggered_by` for any given pipeline, - // the API returns an object for the value instead of an Array. However, - // it's easier to deal with an array in the FE so we convert it. - if (newPipeline.triggered_by) { - if (!Array.isArray(newPipeline.triggered_by)) { - Object.assign(newPipeline, { triggered_by: [newPipeline.triggered_by] }); - } - - if (newPipeline.triggered_by?.length > 0) { - newPipeline.triggered_by.forEach((el) => { - const oldTriggeredBy = oldPipeline.triggered_by?.find((element) => element.id === el.id); - this.parseTriggeredPipelines(oldTriggeredBy, el); - }); - } - } - } - - /** - * Recursively parses the triggered pipelines - * @param {Array} parentPipeline - * @param {Object} pipeline - */ - parseTriggeredPipelines(oldPipeline = {}, newPipeline) { - // keep old value in case it's opened because we're polling - Vue.set(newPipeline, 'isExpanded', oldPipeline.isExpanded || false); - - // add isLoading property - Vue.set(newPipeline, 'isLoading', false); - - if (newPipeline.triggered && newPipeline.triggered.length > 0) { - newPipeline.triggered.forEach((el) => { - const oldTriggered = - oldPipeline.triggered && oldPipeline.triggered.find((element) => element.id === el.id); - this.parseTriggeredPipelines(oldTriggered, el); - }); - } - } - - /** - * Recursively resets all triggered by pipelines - * - * @param {Object} pipeline - */ - resetTriggeredByPipeline(parentPipeline, pipeline) { - parentPipeline.triggered_by.forEach((el) => this.closePipeline(el)); - - if (pipeline.triggered_by && pipeline.triggered_by) { - this.resetTriggeredByPipeline(pipeline, pipeline.triggered_by); - } - } - - /** - * Opens the clicked pipeline and closes all other ones. - * @param {Object} pipeline - */ - openTriggeredByPipeline(parentPipeline, pipeline) { - // first we need to reset all triggeredBy pipelines - this.resetTriggeredByPipeline(parentPipeline, pipeline); - - this.openPipeline(pipeline); - } - - /** - * On click, will close the given pipeline and all nested triggered by pipelines - * - * @param {Object} pipeline - */ - closeTriggeredByPipeline(pipeline) { - this.closePipeline(pipeline); - - if (pipeline.triggered_by && pipeline.triggered_by.length) { - pipeline.triggered_by.forEach((triggeredBy) => this.closeTriggeredByPipeline(triggeredBy)); - } - } - - /** - * Recursively closes all triggered pipelines for the given one. - * - * @param {Object} pipeline - */ - resetTriggeredPipelines(parentPipeline, pipeline) { - parentPipeline.triggered.forEach((el) => this.closePipeline(el)); - - if (pipeline.triggered && pipeline.triggered.length) { - pipeline.triggered.forEach((el) => this.resetTriggeredPipelines(pipeline, el)); - } - } - - /** - * Opens the clicked triggered pipeline and closes all other ones. - * - * @param {Object} pipeline - */ - openTriggeredPipeline(parentPipeline, pipeline) { - this.resetTriggeredPipelines(parentPipeline, pipeline); - - this.openPipeline(pipeline); - } - - /** - * On click, will close the given pipeline and all the nested triggered ones - * @param {Object} pipeline - */ - closeTriggeredPipeline(pipeline) { - this.closePipeline(pipeline); - - if (pipeline.triggered && pipeline.triggered.length) { - pipeline.triggered.forEach((triggered) => this.closeTriggeredPipeline(triggered)); - } - } - - /** - * Utility function, Closes the given pipeline - * @param {Object} pipeline - */ - closePipeline(pipeline) { - Vue.set(pipeline, 'isExpanded', false); - // remove the pipeline from the parameters - this.removeExpandedPipelineToRequestData(pipeline.id); - } - - /** - * Utility function, Opens the given pipeline - * @param {Object} pipeline - */ - openPipeline(pipeline) { - Vue.set(pipeline, 'isExpanded', true); - // add the pipeline to the parameters - this.addExpandedPipelineToRequestData(pipeline.id); - } - // eslint-disable-next-line class-methods-use-this - toggleLoading(pipeline) { - Vue.set(pipeline, 'isLoading', !pipeline.isLoading); - } - - addExpandedPipelineToRequestData(id) { - this.state.expandedPipelines.push(id); - } - - removeExpandedPipelineToRequestData(id) { - this.state.expandedPipelines.splice( - this.state.expandedPipelines.findIndex((el) => el === id), - 1, - ); - } -} |