summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/pipelines
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-06-18 11:18:50 +0000
commit8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781 (patch)
treea77e7fe7a93de11213032ed4ab1f33a3db51b738 /app/assets/javascripts/pipelines
parent00b35af3db1abfe813a778f643dad221aad51fca (diff)
downloadgitlab-ce-8c7f4e9d5f36cff46365a7f8c4b9c21578c1e781.tar.gz
Add latest changes from gitlab-org/gitlab@13-1-stable-ee
Diffstat (limited to 'app/assets/javascripts/pipelines')
-rw-r--r--app/assets/javascripts/pipelines/components/dag/constants.js10
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue136
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_graph.vue299
-rw-r--r--app/assets/javascripts/pipelines/components/dag/drawing_utils.js134
-rw-r--r--app/assets/javascripts/pipelines/components/dag/interactions.js134
-rw-r--r--app/assets/javascripts/pipelines/components/dag/parsing_utils.js164
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines.vue24
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_artifacts.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue84
-rw-r--r--app/assets/javascripts/pipelines/components/test_reports/test_summary.vue2
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue15
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue104
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue64
-rw-r--r--app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue13
-rw-r--r--app/assets/javascripts/pipelines/constants.js3
-rw-r--r--app/assets/javascripts/pipelines/pipeline_details_bundle.js25
-rw-r--r--app/assets/javascripts/pipelines/services/pipelines_service.js13
-rw-r--r--app/assets/javascripts/pipelines/utils.js8
18 files changed, 1175 insertions, 59 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js
new file mode 100644
index 00000000000..51b1fb4f4cc
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/constants.js
@@ -0,0 +1,10 @@
+/* Error constants */
+export const PARSE_FAILURE = 'parse_failure';
+export const LOAD_FAILURE = 'load_failure';
+export const UNSUPPORTED_DATA = 'unsupported_data';
+export const DEFAULT = 'default';
+
+/* Interaction handles */
+export const IS_HIGHLIGHTED = 'dag-highlighted';
+export const LINK_SELECTOR = 'dag-link';
+export const NODE_SELECTOR = 'dag-node';
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
new file mode 100644
index 00000000000..6e0d23ef87f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -0,0 +1,136 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import DagGraph from './dag_graph.vue';
+import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from './constants';
+import { parseData } from './parsing_utils';
+
+export default {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ name: 'Dag',
+ components: {
+ DagGraph,
+ GlAlert,
+ GlLink,
+ GlSprintf,
+ },
+ props: {
+ graphUrl: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ data() {
+ return {
+ showFailureAlert: false,
+ showBetaInfo: true,
+ failureType: null,
+ graphData: null,
+ };
+ },
+ errorTexts: {
+ [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'),
+ [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'),
+ [UNSUPPORTED_DATA]: __('A DAG must have two dependent jobs to be visualized on this tab.'),
+ [DEFAULT]: __('An unknown error occurred while loading this graph.'),
+ },
+ computed: {
+ betaMessage() {
+ return __(
+ 'This feature is currently in beta. We invite you to %{linkStart}give feedback%{linkEnd}.',
+ );
+ },
+ failure() {
+ switch (this.failureType) {
+ case LOAD_FAILURE:
+ return {
+ text: this.$options.errorTexts[LOAD_FAILURE],
+ variant: 'danger',
+ };
+ case PARSE_FAILURE:
+ return {
+ text: this.$options.errorTexts[PARSE_FAILURE],
+ variant: 'danger',
+ };
+ case UNSUPPORTED_DATA:
+ return {
+ text: this.$options.errorTexts[UNSUPPORTED_DATA],
+ variant: 'info',
+ };
+ default:
+ return {
+ text: this.$options.errorTexts[DEFAULT],
+ vatiant: 'danger',
+ };
+ }
+ },
+ shouldDisplayGraph() {
+ return Boolean(!this.showFailureAlert && this.graphData);
+ },
+ },
+ mounted() {
+ const { processGraphData, reportFailure } = this;
+
+ if (!this.graphUrl) {
+ reportFailure();
+ return;
+ }
+
+ axios
+ .get(this.graphUrl)
+ .then(response => {
+ processGraphData(response.data);
+ })
+ .catch(() => reportFailure(LOAD_FAILURE));
+ },
+ methods: {
+ processGraphData(data) {
+ let parsed;
+
+ try {
+ parsed = parseData(data.stages);
+ } catch {
+ this.reportFailure(PARSE_FAILURE);
+ return;
+ }
+
+ if (parsed.links.length < 2) {
+ this.reportFailure(UNSUPPORTED_DATA);
+ return;
+ }
+
+ this.graphData = parsed;
+ },
+ hideAlert() {
+ this.showFailureAlert = false;
+ },
+ hideBetaInfo() {
+ this.showBetaInfo = false;
+ },
+ reportFailure(type) {
+ this.showFailureAlert = true;
+ this.failureType = type;
+ },
+ },
+};
+</script>
+<template>
+ <div>
+ <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">
+ {{ failure.text }}
+ </gl-alert>
+
+ <gl-alert v-if="showBetaInfo" @dismiss="hideBetaInfo">
+ <gl-sprintf :message="betaMessage">
+ <template #link="{ content }">
+ <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/220368" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+ <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
new file mode 100644
index 00000000000..063ec091e4d
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
@@ -0,0 +1,299 @@
+<script>
+import * as d3 from 'd3';
+import { uniqueId } from 'lodash';
+import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants';
+import {
+ highlightLinks,
+ restoreLinks,
+ toggleLinkHighlight,
+ togglePathHighlights,
+} from './interactions';
+import { getMaxNodes, removeOrphanNodes } from './parsing_utils';
+import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
+
+export default {
+ viewOptions: {
+ baseHeight: 300,
+ baseWidth: 1000,
+ minNodeHeight: 60,
+ nodeWidth: 16,
+ nodePadding: 25,
+ paddingForLabels: 100,
+ labelMargin: 8,
+
+ baseOpacity: 0.8,
+ containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join(
+ ' ',
+ ),
+ },
+ gitLabColorRotation: [
+ '#e17223',
+ '#83ab4a',
+ '#5772ff',
+ '#b24800',
+ '#25d2d2',
+ '#006887',
+ '#487900',
+ '#d84280',
+ '#3547de',
+ '#6f3500',
+ '#006887',
+ '#275600',
+ '#b31756',
+ ],
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ color: () => {},
+ width: 0,
+ height: 0,
+ };
+ },
+ mounted() {
+ let countedAndTransformed;
+
+ try {
+ countedAndTransformed = this.transformData(this.graphData);
+ } catch {
+ this.$emit('onFailure', PARSE_FAILURE);
+ return;
+ }
+
+ this.drawGraph(countedAndTransformed);
+ },
+ methods: {
+ addSvg() {
+ return d3
+ .select('.dag-graph-container')
+ .append('svg')
+ .attr('viewBox', [0, 0, this.width, this.height])
+ .attr('width', this.width)
+ .attr('height', this.height);
+ },
+
+ appendLinks(link) {
+ return (
+ link
+ .append('path')
+ .attr('d', (d, i) => createLinkPath(d, i, this.$options.viewOptions.nodeWidth))
+ .attr('stroke', ({ gradId }) => `url(#${gradId})`)
+ .style('stroke-linejoin', 'round')
+ // minus two to account for the rounded nodes
+ .attr('stroke-width', ({ width }) => Math.max(1, width - 2))
+ .attr('clip-path', ({ clipId }) => `url(#${clipId})`)
+ );
+ },
+
+ appendLinkInteractions(link) {
+ return link
+ .on('mouseover', highlightLinks)
+ .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity))
+ .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity));
+ },
+
+ appendNodeInteractions(node) {
+ return node.on(
+ 'click',
+ togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity),
+ );
+ },
+
+ appendLabelAsForeignObject(d, i, n) {
+ const currentNode = n[i];
+ const { height, wrapperWidth, width, x, y, textAlign } = labelPosition(d, {
+ ...this.$options.viewOptions,
+ width: this.width,
+ });
+
+ const labelClasses = [
+ 'gl-display-flex',
+ 'gl-pointer-events-none',
+ 'gl-flex-direction-column',
+ 'gl-justify-content-center',
+ 'gl-overflow-wrap-break',
+ ].join(' ');
+
+ return (
+ d3
+ .select(currentNode)
+ .attr('requiredFeatures', 'http://www.w3.org/TR/SVG11/feature#Extensibility')
+ .attr('height', height)
+ /*
+ items with a 'max-content' width will have a wrapperWidth for the foreignObject
+ */
+ .attr('width', wrapperWidth || width)
+ .attr('x', x)
+ .attr('y', y)
+ .classed('gl-overflow-visible', true)
+ .append('xhtml:div')
+ .classed(labelClasses, true)
+ .style('height', height)
+ .style('width', width)
+ .style('text-align', textAlign)
+ .text(({ name }) => name)
+ );
+ },
+
+ createAndAssignId(datum, field, modifier = '') {
+ const id = uniqueId(modifier);
+ /* eslint-disable-next-line no-param-reassign */
+ datum[field] = id;
+ return id;
+ },
+
+ createClip(link) {
+ return link
+ .append('clipPath')
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'clipId', 'dag-clip');
+ })
+ .append('path')
+ .attr('d', calculateClip);
+ },
+
+ createGradient(link) {
+ const gradient = link
+ .append('linearGradient')
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'gradId', 'dag-grad');
+ })
+ .attr('gradientUnits', 'userSpaceOnUse')
+ .attr('x1', ({ source }) => source.x1)
+ .attr('x2', ({ target }) => target.x0);
+
+ gradient
+ .append('stop')
+ .attr('offset', '0%')
+ .attr('stop-color', ({ source }) => this.color(source));
+
+ gradient
+ .append('stop')
+ .attr('offset', '100%')
+ .attr('stop-color', ({ target }) => this.color(target));
+ },
+
+ createLinks(svg, linksData) {
+ const links = this.generateLinks(svg, linksData);
+ this.createGradient(links);
+ this.createClip(links);
+ this.appendLinks(links);
+ this.appendLinkInteractions(links);
+ },
+
+ createNodes(svg, nodeData) {
+ const nodes = this.generateNodes(svg, nodeData);
+ this.labelNodes(svg, nodeData);
+ this.appendNodeInteractions(nodes);
+ },
+
+ drawGraph({ maxNodesPerLayer, linksAndNodes }) {
+ const {
+ baseWidth,
+ baseHeight,
+ minNodeHeight,
+ nodeWidth,
+ nodePadding,
+ paddingForLabels,
+ } = this.$options.viewOptions;
+
+ this.width = baseWidth;
+ this.height = baseHeight + maxNodesPerLayer * minNodeHeight;
+ this.color = this.initColors();
+
+ const { links, nodes } = createSankey({
+ width: this.width,
+ height: this.height,
+ nodeWidth,
+ nodePadding,
+ paddingForLabels,
+ })(linksAndNodes);
+
+ const svg = this.addSvg();
+ this.createLinks(svg, links);
+ this.createNodes(svg, nodes);
+ },
+
+ generateLinks(svg, linksData) {
+ return svg
+ .append('g')
+ .attr('fill', 'none')
+ .attr('stroke-opacity', this.$options.viewOptions.baseOpacity)
+ .selectAll(`.${LINK_SELECTOR}`)
+ .data(linksData)
+ .enter()
+ .append('g')
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'uid', LINK_SELECTOR);
+ })
+ .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true);
+ },
+
+ generateNodes(svg, nodeData) {
+ const { nodeWidth } = this.$options.viewOptions;
+
+ return svg
+ .append('g')
+ .selectAll(`.${NODE_SELECTOR}`)
+ .data(nodeData)
+ .enter()
+ .append('line')
+ .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true)
+ .attr('id', d => {
+ return this.createAndAssignId(d, 'uid', NODE_SELECTOR);
+ })
+ .attr('stroke', d => {
+ const color = this.color(d);
+ /* eslint-disable-next-line no-param-reassign */
+ d.color = color;
+ return color;
+ })
+ .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);
+ },
+
+ labelNodes(svg, nodeData) {
+ return svg
+ .append('g')
+ .classed('gl-font-sm', true)
+ .selectAll('text')
+ .data(nodeData)
+ .enter()
+ .append('foreignObject')
+ .each(this.appendLabelAsForeignObject);
+ },
+
+ initColors() {
+ const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
+ return ({ name }) => colorFn(name);
+ },
+
+ transformData(parsed) {
+ const baseLayout = createSankey()(parsed);
+ const cleanedNodes = removeOrphanNodes(baseLayout.nodes);
+ const maxNodesPerLayer = getMaxNodes(cleanedNodes);
+
+ return {
+ maxNodesPerLayer,
+ linksAndNodes: {
+ links: parsed.links,
+ nodes: cleanedNodes,
+ },
+ };
+ },
+ },
+};
+</script>
+<template>
+ <div :class="$options.viewOptions.containerClasses" data-testid="dag-graph-container">
+ <!-- graph goes here -->
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/drawing_utils.js b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js
new file mode 100644
index 00000000000..d56addc473f
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/drawing_utils.js
@@ -0,0 +1,134 @@
+import * as d3 from 'd3';
+import { sankey, sankeyLeft } from 'd3-sankey';
+
+export const calculateClip = ({ y0, y1, source, target, width }) => {
+ /*
+ Because large link values can overrun their box, we create a clip path
+ to trim off the excess in charts that have few nodes per column and are
+ therefore tall.
+
+ The box is created by
+ M: moving to outside midpoint of the source node
+ V: drawing a vertical line to maximum of the bottom link edge or
+ the lowest edge of the node (can be d.y0 or d.y1 depending on the link's path)
+ H: drawing a horizontal line to the outside edge of the destination node
+ V: drawing a vertical line back up to the minimum of the top link edge or
+ the highest edge of the node (can be d.y0 or d.y1 depending on the link's path)
+ H: drawing a horizontal line back to the outside edge of the source node
+ Z: closing the path, back to the start point
+ */
+
+ const bottomLinkEdge = Math.max(y1, y0) + width / 2;
+ const topLinkEdge = Math.min(y0, y1) - width / 2;
+
+ /* eslint-disable @gitlab/require-i18n-strings */
+ return `
+ M${source.x0}, ${y1}
+ V${Math.max(bottomLinkEdge, y0, y1)}
+ H${target.x1}
+ V${Math.min(topLinkEdge, y0, y1)}
+ H${source.x0}
+ Z
+ `;
+ /* eslint-enable @gitlab/require-i18n-strings */
+};
+
+export const createLinkPath = ({ y0, y1, source, target, width }, idx, nodeWidth) => {
+ /*
+ Creates a series of staggered midpoints for the link paths, so they
+ don't run along one channel and can be distinguished.
+
+ First, get a point staggered by index and link width, modulated by the link box
+ to find a point roughly between the nodes.
+
+ Then offset it by nodeWidth, so it doesn't run under any nodes at the left.
+
+ Determine where it would overlap at the right.
+
+ Finally, select the leftmost of these options:
+ - offset from the source node based on index + fudge;
+ - a fuzzy offset from the right node, using Math.random adds a little blur
+ - a hard offset from the end node, if random pushes it over
+
+ Then draw a line from the start node to the bottom-most point of the midline
+ up to the topmost point in that line and then to the middle of the end node
+ */
+
+ const xValRaw = source.x1 + (((idx + 1) * width) % (target.x1 - source.x0));
+ const xValMin = xValRaw + nodeWidth;
+ const overlapPoint = source.x1 + (target.x0 - source.x1);
+ const xValMax = overlapPoint - nodeWidth * 1.4;
+
+ const midPointX = Math.min(xValMin, target.x0 - nodeWidth * 4 * Math.random(), xValMax);
+
+ return d3.line()([
+ [(source.x0 + source.x1) / 2, y0],
+ [midPointX, y0],
+ [midPointX, y1],
+ [(target.x0 + target.x1) / 2, y1],
+ ]);
+};
+
+/*
+ createSankey calls the d3 layout to generate the relationships and positioning
+ values for the nodes and links in the graph.
+ */
+
+export const createSankey = ({
+ width = 10,
+ height = 10,
+ nodeWidth = 10,
+ nodePadding = 10,
+ paddingForLabels = 1,
+} = {}) => {
+ const sankeyGenerator = sankey()
+ .nodeId(({ name }) => name)
+ .nodeAlign(sankeyLeft)
+ .nodeWidth(nodeWidth)
+ .nodePadding(nodePadding)
+ .extent([
+ [paddingForLabels, paddingForLabels],
+ [width - paddingForLabels, height - paddingForLabels],
+ ]);
+ return ({ nodes, links }) =>
+ sankeyGenerator({
+ nodes: nodes.map(d => ({ ...d })),
+ links: links.map(d => ({ ...d })),
+ });
+};
+
+export const labelPosition = ({ x0, x1, y0, y1 }, viewOptions) => {
+ const { paddingForLabels, labelMargin, nodePadding, width } = viewOptions;
+
+ const firstCol = x0 <= paddingForLabels;
+ const lastCol = x1 >= width - paddingForLabels;
+
+ if (firstCol) {
+ return {
+ x: 0 + labelMargin,
+ y: y0,
+ height: `${y1 - y0}px`,
+ width: paddingForLabels - 2 * labelMargin,
+ textAlign: 'right',
+ };
+ }
+
+ if (lastCol) {
+ return {
+ x: width - paddingForLabels + labelMargin,
+ y: y0,
+ height: `${y1 - y0}px`,
+ width: paddingForLabels - 2 * labelMargin,
+ textAlign: 'left',
+ };
+ }
+
+ return {
+ x: (x1 + x0) / 2,
+ y: y0 - nodePadding,
+ height: `${nodePadding}px`,
+ width: 'max-content',
+ wrapperWidth: paddingForLabels - 2 * labelMargin,
+ textAlign: x0 < width / 2 ? 'left' : 'right',
+ };
+};
diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js
new file mode 100644
index 00000000000..c9008730c90
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/interactions.js
@@ -0,0 +1,134 @@
+import * as d3 from 'd3';
+import { LINK_SELECTOR, NODE_SELECTOR, IS_HIGHLIGHTED } from './constants';
+
+export const highlightIn = 1;
+export const highlightOut = 0.2;
+
+const getCurrent = (idx, collection) => d3.select(collection[idx]);
+const currentIsLive = (idx, collection) => getCurrent(idx, collection).classed(IS_HIGHLIGHTED);
+const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
+const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${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 renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity);
+const renewNodes = selection => selection.attr('stroke', d => d.color);
+
+const getAllLinkAncestors = node => {
+ if (node.targetLinks) {
+ return node.targetLinks.flatMap(n => {
+ return [n.uid, ...getAllLinkAncestors(n.source)];
+ });
+ }
+
+ return [];
+};
+
+const getAllNodeAncestors = node => {
+ let allNodes = [];
+
+ if (node.targetLinks) {
+ allNodes = node.targetLinks.flatMap(n => {
+ return getAllNodeAncestors(n.source);
+ });
+ }
+
+ return [...allNodes, node.uid];
+};
+
+export const highlightLinks = (d, idx, collection) => {
+ const currentLink = getCurrent(idx, collection);
+ const currentSourceNode = d3.select(`#${d.source.uid}`);
+ const currentTargetNode = d3.select(`#${d.target.uid}`);
+
+ /* Higlight selected link, de-emphasize others */
+ backgroundLinks(getOtherLinks());
+ foregroundLinks(currentLink);
+
+ /* Do the same to related nodes */
+ backgroundNodes(getNodesNotLive());
+ foregroundNodes(currentSourceNode);
+ foregroundNodes(currentTargetNode);
+};
+
+const highlightPath = (parentLinks, parentNodes) => {
+ /* de-emphasize everything else */
+ backgroundLinks(getOtherLinks());
+ backgroundNodes(getNodesNotLive());
+
+ /* highlight correct links */
+ parentLinks.forEach(id => {
+ foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true);
+ });
+
+ /* highlight correct nodes */
+ parentNodes.forEach(id => {
+ foregroundNodes(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true);
+ });
+};
+
+const restorePath = (parentLinks, parentNodes, baseOpacity) => {
+ parentLinks.forEach(id => {
+ renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
+ });
+
+ parentNodes.forEach(id => {
+ d3.select(`#${id}`).classed(IS_HIGHLIGHTED, false);
+ });
+
+ if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) {
+ renewLinks(getOtherLinks(), baseOpacity);
+ renewNodes(getNodesNotLive());
+ return;
+ }
+
+ backgroundLinks(getOtherLinks());
+ backgroundNodes(getNodesNotLive());
+};
+
+export const restoreLinks = (baseOpacity, d, idx, collection) => {
+ /* in this case, it has just been clicked */
+ if (currentIsLive(idx, collection)) {
+ return;
+ }
+
+ /*
+ if there exist live links, reset to highlight out / pale
+ otherwise, reset to base
+ */
+
+ if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) {
+ renewLinks(d3.selectAll(`.${LINK_SELECTOR}`), baseOpacity);
+ renewNodes(d3.selectAll(`.${NODE_SELECTOR}`));
+ return;
+ }
+
+ backgroundLinks(getOtherLinks());
+ backgroundNodes(getNodesNotLive());
+};
+
+export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => {
+ if (currentIsLive(idx, collection)) {
+ restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity);
+ return;
+ }
+
+ highlightPath([d.uid], [d.source.uid, d.target.uid]);
+};
+
+export const togglePathHighlights = (baseOpacity, d, idx, collection) => {
+ const parentLinks = getAllLinkAncestors(d);
+ const parentNodes = getAllNodeAncestors(d);
+ const currentNode = getCurrent(idx, collection);
+
+ /* if this node is already live, make it unlive and reset its path */
+ if (currentIsLive(idx, collection)) {
+ currentNode.classed(IS_HIGHLIGHTED, false);
+ restorePath(parentLinks, parentNodes, baseOpacity);
+ return;
+ }
+
+ highlightPath(parentLinks, parentNodes);
+};
diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
new file mode 100644
index 00000000000..3234f80ee91
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js
@@ -0,0 +1,164 @@
+import { uniqWith, isEqual } from 'lodash';
+
+/*
+ The following functions are the main engine in transforming the data as
+ received from the endpoint into the format the d3 graph expects.
+
+ Input is of the form:
+ [stages]
+ stages: {name, groups}
+ groups: [{ name, size, jobs }]
+ name is a group name; in the case that the group has one job, it is
+ also the job name
+ size is the number of parallel jobs
+ jobs: [{ name, needs}]
+ job name is either the same as the group name or group x/y
+
+ Output is of the form:
+ { nodes: [node], links: [link] }
+ node: { name, category }, + unused info passed through
+ link: { source, target, value }, with source & target being node names
+ and value being a constant
+
+ We create nodes, create links, and then dedupe the links, so that in the case where
+ job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link
+ from job 1 to job 2 then another from job 2 to job 4.
+
+ CREATE NODES
+ stage.name -> node.category
+ stage.group.name -> node.name (this is the group name if there are parallel jobs)
+ stage.group.jobs -> node.jobs
+ stage.group.size -> node.size
+
+ CREATE LINKS
+ stages.groups.name -> target
+ stages.groups.needs.each -> source (source is the name of the group, not the parallel job)
+ 10 -> value (constant)
+ */
+
+export const createNodes = data => {
+ return data.flatMap(({ groups, name }) => {
+ return groups.map(group => {
+ return { ...group, category: name };
+ });
+ });
+};
+
+export const createNodeDict = nodes => {
+ return nodes.reduce((acc, node) => {
+ const newNode = {
+ ...node,
+ needs: node.jobs.map(job => job.needs || []).flat(),
+ };
+
+ if (node.size > 1) {
+ node.jobs.forEach(job => {
+ acc[job.name] = newNode;
+ });
+ }
+
+ acc[node.name] = newNode;
+ return acc;
+ }, {});
+};
+
+export const createNodesStructure = data => {
+ const nodes = createNodes(data);
+ const nodeDict = createNodeDict(nodes);
+
+ return { nodes, nodeDict };
+};
+
+export const makeLinksFromNodes = (nodes, nodeDict) => {
+ const constantLinkValue = 10; // all links are the same weight
+ return nodes
+ .map(group => {
+ return group.jobs.map(job => {
+ if (!job.needs) {
+ return [];
+ }
+
+ return job.needs.map(needed => {
+ return {
+ source: nodeDict[needed]?.name,
+ target: group.name,
+ value: constantLinkValue,
+ };
+ });
+ });
+ })
+ .flat(2);
+};
+
+export const getAllAncestors = (nodes, nodeDict) => {
+ const needs = nodes
+ .map(node => {
+ return nodeDict[node].needs || '';
+ })
+ .flat()
+ .filter(Boolean);
+
+ if (needs.length) {
+ return [...needs, ...getAllAncestors(needs, nodeDict)];
+ }
+
+ return [];
+};
+
+export const filterByAncestors = (links, nodeDict) =>
+ links.filter(({ target, source }) => {
+ /*
+
+ for every link, check out it's target
+ for every target, get the target node's needs
+ then drop the current link source from that list
+
+ call a function to get all ancestors, recursively
+ is the current link's source in the list of all parents?
+ then we drop this link
+
+ */
+ const targetNode = target;
+ const targetNodeNeeds = nodeDict[targetNode].needs;
+ const targetNodeNeedsMinusSource = targetNodeNeeds.filter(need => need !== source);
+
+ const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict);
+ return !allAncestors.includes(source);
+ });
+
+export const parseData = data => {
+ const { nodes, nodeDict } = createNodesStructure(data);
+ const allLinks = makeLinksFromNodes(nodes, nodeDict);
+ const filteredLinks = filterByAncestors(allLinks, nodeDict);
+ const links = uniqWith(filteredLinks, isEqual);
+
+ return { nodes, links };
+};
+
+/*
+ The number of nodes in the most populous generation drives the height of the graph.
+*/
+
+export const getMaxNodes = nodes => {
+ const counts = nodes.reduce((acc, { layer }) => {
+ if (!acc[layer]) {
+ acc[layer] = 0;
+ }
+
+ acc[layer] += 1;
+
+ return acc;
+ }, []);
+
+ return Math.max(...counts);
+};
+
+/*
+ Because we cannot know if a node is part of a relationship until after we
+ generate the links with createSankey, this function is used after the first call
+ to find nodes that have no relations.
+*/
+
+export const removeOrphanNodes = sankeyfiedNodes => {
+ return sankeyfiedNodes.filter(node => node.sourceLinks.length || node.targetLinks.length);
+};
diff --git a/app/assets/javascripts/pipelines/components/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines.vue
index fc93635bdb5..dbf29b0c29c 100644
--- a/app/assets/javascripts/pipelines/components/pipelines.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines.vue
@@ -10,7 +10,8 @@ import NavigationControls from './nav_controls.vue';
import { getParameterByName } from '../../lib/utils/common_utils';
import CIPaginationMixin from '../../vue_shared/mixins/ci_pagination_api_mixin';
import PipelinesFilteredSearch from './pipelines_filtered_search.vue';
-import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING } from '../constants';
+import { validateParams } from '../utils';
+import { ANY_TRIGGER_AUTHOR, RAW_TEXT_WARNING, FILTER_TAG_IDENTIFIER } from '../constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
@@ -86,6 +87,10 @@ export default {
type: String,
required: true,
},
+ params: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
@@ -220,10 +225,13 @@ export default {
canFilterPipelines() {
return this.glFeatures.filterPipelinesSearch;
},
+ validatedParams() {
+ return validateParams(this.params);
+ },
},
created() {
this.service = new PipelinesService(this.endpoint);
- this.requestData = { page: this.page, scope: this.scope };
+ this.requestData = { page: this.page, scope: this.scope, ...this.validatedParams };
},
methods: {
successCallback(resp) {
@@ -258,10 +266,18 @@ export default {
filters.forEach(filter => {
// do not add Any for username query param, so we
// can fetch all trigger authors
- if (filter.type && filter.value.data !== ANY_TRIGGER_AUTHOR) {
+ if (
+ filter.type &&
+ filter.value.data !== ANY_TRIGGER_AUTHOR &&
+ filter.type !== FILTER_TAG_IDENTIFIER
+ ) {
this.requestData[filter.type] = filter.value.data;
}
+ if (filter.type === FILTER_TAG_IDENTIFIER) {
+ this.requestData.ref = filter.value.data;
+ }
+
if (!filter.type) {
createFlash(RAW_TEXT_WARNING, 'warning');
}
@@ -304,8 +320,8 @@ export default {
<pipelines-filtered-search
v-if="canFilterPipelines"
- :pipelines="state.pipelines"
:project-id="projectId"
+ :params="validatedParams"
@filterPipelines="filterPipelines"
/>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
index 2212428ced5..59c066b2683 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_artifacts.vue
@@ -35,7 +35,7 @@ export default {
<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 }} artifacts</gl-link
+ >Download {{ artifact.name }} artifact</gl-link
>
</li>
</ul>
diff --git a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
index 8f9c3eb70a2..0505a8668d1 100644
--- a/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
+++ b/app/assets/javascripts/pipelines/components/pipelines_filtered_search.vue
@@ -3,74 +3,93 @@ import { GlFilteredSearch } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue';
import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue';
-import Api from '~/api';
-import createFlash from '~/flash';
-import { FETCH_AUTHOR_ERROR_MESSAGE, FETCH_BRANCH_ERROR_MESSAGE } from '../constants';
+import PipelineStatusToken from './tokens/pipeline_status_token.vue';
+import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue';
+import { map } from 'lodash';
export default {
+ userType: 'username',
+ branchType: 'ref',
+ tagType: 'tag',
+ statusType: 'status',
+ defaultTokensLength: 1,
components: {
GlFilteredSearch,
},
props: {
- pipelines: {
- type: Array,
- required: true,
- },
projectId: {
type: String,
required: true,
},
+ params: {
+ type: Object,
+ required: true,
+ },
},
data() {
return {
- projectUsers: null,
- projectBranches: null,
+ internalValue: [],
};
},
computed: {
+ selectedTypes() {
+ return this.value.map(i => i.type);
+ },
tokens() {
return [
{
- type: 'username',
+ type: this.$options.userType,
icon: 'user',
title: s__('Pipeline|Trigger author'),
unique: true,
token: PipelineTriggerAuthorToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
- triggerAuthors: this.projectUsers,
projectId: this.projectId,
},
{
- type: 'ref',
+ type: this.$options.branchType,
icon: 'branch',
title: s__('Pipeline|Branch name'),
unique: true,
token: PipelineBranchNameToken,
operators: [{ value: '=', description: __('is'), default: 'true' }],
- branches: this.projectBranches,
projectId: this.projectId,
+ disabled: this.selectedTypes.includes(this.$options.tagType),
+ },
+ {
+ type: this.$options.tagType,
+ icon: 'tag',
+ title: s__('Pipeline|Tag name'),
+ unique: true,
+ token: PipelineTagNameToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
+ projectId: this.projectId,
+ disabled: this.selectedTypes.includes(this.$options.branchType),
+ },
+ {
+ type: this.$options.statusType,
+ icon: 'status',
+ title: s__('Pipeline|Status'),
+ unique: true,
+ token: PipelineStatusToken,
+ operators: [{ value: '=', description: __('is'), default: 'true' }],
},
];
},
- },
- created() {
- Api.projectUsers(this.projectId)
- .then(users => {
- this.projectUsers = users;
- })
- .catch(err => {
- createFlash(FETCH_AUTHOR_ERROR_MESSAGE);
- throw err;
- });
-
- Api.branches(this.projectId)
- .then(({ data }) => {
- this.projectBranches = data.map(branch => branch.name);
- })
- .catch(err => {
- createFlash(FETCH_BRANCH_ERROR_MESSAGE);
- throw err;
- });
+ parsedParams() {
+ return map(this.params, (val, key) => ({
+ type: key,
+ value: { data: val, operator: '=' },
+ }));
+ },
+ value: {
+ get() {
+ return this.internalValue.length > 0 ? this.internalValue : this.parsedParams;
+ },
+ set(value) {
+ this.internalValue = value;
+ },
+ },
},
methods: {
onSubmit(filters) {
@@ -83,6 +102,7 @@ export default {
<template>
<div class="row-content-block">
<gl-filtered-search
+ v-model="value"
:placeholder="__('Filter pipelines')"
:available-tokens="tokens"
@submit="onSubmit"
diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
index 80a1c83f171..67646c537bd 100644
--- a/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
+++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary.vue
@@ -68,7 +68,7 @@ export default {
<template>
<div>
<div class="row">
- <div class="col-12 d-flex prepend-top-8 align-items-center">
+ <div class="col-12 d-flex gl-mt-3 align-items-center">
<gl-deprecated-button
v-if="showBack"
size="sm"
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
index a7a3f986255..da14bb2d308 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_branch_name_token.vue
@@ -23,15 +23,18 @@ export default {
},
data() {
return {
- branches: this.config.branches,
+ branches: null,
loading: true,
};
},
+ created() {
+ this.fetchBranches();
+ },
methods: {
- fetchBranchBySearchTerm(searchTerm) {
- Api.branches(this.config.projectId, searchTerm)
- .then(res => {
- this.branches = res.data.map(branch => branch.name);
+ fetchBranches(searchterm) {
+ Api.branches(this.config.projectId, searchterm)
+ .then(({ data }) => {
+ this.branches = data.map(branch => branch.name);
this.loading = false;
})
.catch(err => {
@@ -41,7 +44,7 @@ export default {
});
},
searchBranches: debounce(function debounceSearch({ data }) {
- this.fetchBranchBySearchTerm(data);
+ this.fetchBranches(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue
new file mode 100644
index 00000000000..dc43d94f4fd
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_status_token.vue
@@ -0,0 +1,104 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui';
+import { s__ } from '~/locale';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ statuses() {
+ return [
+ {
+ class: 'ci-status-icon-canceled',
+ icon: 'status_canceled',
+ text: s__('Pipeline|Canceled'),
+ value: 'canceled',
+ },
+ {
+ class: 'ci-status-icon-created',
+ icon: 'status_created',
+ text: s__('Pipeline|Created'),
+ value: 'created',
+ },
+ {
+ class: 'ci-status-icon-failed',
+ icon: 'status_failed',
+ text: s__('Pipeline|Failed'),
+ value: 'failed',
+ },
+ {
+ class: 'ci-status-icon-manual',
+ icon: 'status_manual',
+ text: s__('Pipeline|Manual'),
+ value: 'manual',
+ },
+ {
+ class: 'ci-status-icon-success',
+ icon: 'status_success',
+ text: s__('Pipeline|Passed'),
+ value: 'success',
+ },
+ {
+ class: 'ci-status-icon-pending',
+ icon: 'status_pending',
+ text: s__('Pipeline|Pending'),
+ value: 'pending',
+ },
+ {
+ class: 'ci-status-icon-running',
+ icon: 'status_running',
+ text: s__('Pipeline|Running'),
+ value: 'running',
+ },
+ {
+ class: 'ci-status-icon-skipped',
+ icon: 'status_skipped',
+ text: s__('Pipeline|Skipped'),
+ value: 'skipped',
+ },
+ ];
+ },
+ findActiveStatus() {
+ return this.statuses.find(status => status.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">
+ <div :class="findActiveStatus.class">
+ <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" />
+ </div>
+ <span>{{ findActiveStatus.text }}</span>
+ </div>
+ </template>
+ <template #suggestions>
+ <gl-filtered-search-suggestion
+ v-for="(status, index) in statuses"
+ :key="index"
+ :value="status.value"
+ >
+ <div class="gl-display-flex" :class="status.class">
+ <gl-icon :name="status.icon" class="gl-mr-3" />
+ <span>{{ status.text }}</span>
+ </div>
+ </gl-filtered-search-suggestion>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue
new file mode 100644
index 00000000000..7b209c5fa12
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_tag_name_token.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui';
+import Api from '~/api';
+import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../constants';
+import createFlash from '~/flash';
+import { debounce } from 'lodash';
+
+export default {
+ components: {
+ GlFilteredSearchToken,
+ GlFilteredSearchSuggestion,
+ GlLoadingIcon,
+ },
+ props: {
+ config: {
+ type: Object,
+ required: true,
+ },
+ value: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ tags: null,
+ loading: true,
+ };
+ },
+ created() {
+ this.fetchTags();
+ },
+ methods: {
+ fetchTags(searchTerm) {
+ Api.tags(this.config.projectId, searchTerm)
+ .then(({ data }) => {
+ this.tags = data.map(tag => tag.name);
+ this.loading = false;
+ })
+ .catch(err => {
+ createFlash(FETCH_TAG_ERROR_MESSAGE);
+ this.loading = false;
+ throw err;
+ });
+ },
+ searchTags: debounce(function debounceSearch({ data }) {
+ this.fetchTags(data);
+ }, FILTER_PIPELINES_SEARCH_DELAY),
+ },
+};
+</script>
+
+<template>
+ <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags">
+ <template #suggestions>
+ <gl-loading-icon v-if="loading" />
+ <template v-else>
+ <gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag">
+ {{ tag }}
+ </gl-filtered-search-suggestion>
+ </template>
+ </template>
+ </gl-filtered-search-token>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
index 83e3558e1a1..4062a3b11bb 100644
--- a/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
+++ b/app/assets/javascripts/pipelines/components/tokens/pipeline_trigger_author_token.vue
@@ -36,7 +36,7 @@ export default {
},
data() {
return {
- users: this.config.triggerAuthors,
+ users: [],
loading: true,
};
},
@@ -50,11 +50,14 @@ export default {
});
},
},
+ created() {
+ this.fetchProjectUsers();
+ },
methods: {
- fetchAuthorBySearchTerm(searchTerm) {
+ fetchProjectUsers(searchTerm) {
Api.projectUsers(this.config.projectId, searchTerm)
- .then(res => {
- this.users = res;
+ .then(users => {
+ this.users = users;
this.loading = false;
})
.catch(err => {
@@ -64,7 +67,7 @@ export default {
});
},
searchAuthors: debounce(function debounceSearch({ data }) {
- this.fetchAuthorBySearchTerm(data);
+ this.fetchProjectUsers(data);
}, FILTER_PIPELINES_SEARCH_DELAY),
},
};
diff --git a/app/assets/javascripts/pipelines/constants.js b/app/assets/javascripts/pipelines/constants.js
index d694430830b..c709f329728 100644
--- a/app/assets/javascripts/pipelines/constants.js
+++ b/app/assets/javascripts/pipelines/constants.js
@@ -5,6 +5,8 @@ export const PIPELINES_TABLE = 'PIPELINES_TABLE';
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 FILTER_TAG_IDENTIFIER = 'tag';
export const TestStatus = {
FAILED: 'failed',
@@ -14,6 +16,7 @@ export const TestStatus = {
export const FETCH_AUTHOR_ERROR_MESSAGE = __('There was a problem fetching project users.');
export const FETCH_BRANCH_ERROR_MESSAGE = __('There was a problem fetching project branches.');
+export const FETCH_TAG_ERROR_MESSAGE = __('There was a problem fetching project tags.');
export const RAW_TEXT_WARNING = s__(
'Pipeline|Raw text search is not currently supported. Please use the available search tokens.',
);
diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
index 01295874e56..90109598542 100644
--- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js
+++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js
@@ -4,6 +4,7 @@ import Translate from '~/vue_shared/translate';
import { __ } from '~/locale';
import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility';
import pipelineGraph from './components/graph/graph_component.vue';
+import Dag from './components/dag/dag.vue';
import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin';
import PipelinesMediator from './pipeline_details_mediator';
import pipelineHeader from './components/header_component.vue';
@@ -144,6 +145,29 @@ const createTestDetails = detailsEndpoint => {
.catch(() => {});
};
+const createDagApp = () => {
+ if (!window.gon?.features?.dagPipelineTab) {
+ return;
+ }
+
+ const el = document.querySelector('#js-pipeline-dag-vue');
+ const graphUrl = el?.dataset?.pipelineDataPath;
+ // eslint-disable-next-line no-new
+ new Vue({
+ el,
+ components: {
+ Dag,
+ },
+ render(createElement) {
+ return createElement('dag', {
+ props: {
+ graphUrl,
+ },
+ });
+ },
+ });
+};
+
export default () => {
const { dataset } = document.querySelector('.js-pipeline-details-vue');
const mediator = new PipelinesMediator({ endpoint: dataset.endpoint });
@@ -153,4 +177,5 @@ export default () => {
createPipelineHeaderApp(mediator);
createPipelinesTabs(dataset);
createTestDetails(dataset.testReportsCountEndpoint);
+ createDagApp();
};
diff --git a/app/assets/javascripts/pipelines/services/pipelines_service.js b/app/assets/javascripts/pipelines/services/pipelines_service.js
index ae94d7a7ca0..0b06bcf243a 100644
--- a/app/assets/javascripts/pipelines/services/pipelines_service.js
+++ b/app/assets/javascripts/pipelines/services/pipelines_service.js
@@ -1,5 +1,6 @@
import axios from '../../lib/utils/axios_utils';
import Api from '~/api';
+import { validateParams } from '../utils';
export default class PipelinesService {
/**
@@ -19,18 +20,10 @@ export default class PipelinesService {
}
getPipelines(data = {}) {
- const { scope, page, username, ref } = data;
+ const { scope, page } = data;
const { CancelToken } = axios;
- const queryParams = { scope, page };
-
- if (username) {
- queryParams.username = username;
- }
-
- if (ref) {
- queryParams.ref = ref;
- }
+ const queryParams = { scope, page, ...validateParams(data) };
this.cancelationSource = CancelToken.source();
diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js
new file mode 100644
index 00000000000..9dbc8073d3a
--- /dev/null
+++ b/app/assets/javascripts/pipelines/utils.js
@@ -0,0 +1,8 @@
+import { pickBy } from 'lodash';
+import { SUPPORTED_FILTER_PARAMETERS } from './constants';
+
+export const validateParams = params => {
+ return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val);
+};
+
+export default () => {};