Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/pipelines/components/dag')
-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
6 files changed, 877 insertions, 0 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);
+};