diff options
Diffstat (limited to 'app/assets/javascripts/pipelines/components')
14 files changed, 1136 insertions, 49 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), }, }; |