Commit 793900ed authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch '215517-initialize-the-graph' into 'master'

DAG Visualization: Return of the Graph

See merge request gitlab-org/gitlab!32890
parents 0d313cbd fba4ebfb
export const PARSE_FAILURE = 'parse_failure';
export const LOAD_FAILURE = 'load_failure';
export const UNSUPPORTED_DATA = 'unsupported_data';
export const DEFAULT = 'default';
<script>
import { GlAlert } 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 './utils';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Dag',
components: {
DagGraph,
GlAlert,
},
props: {
......@@ -18,15 +23,47 @@ export default {
data() {
return {
showFailureAlert: false,
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: {
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 !this.showFailureAlert;
return Boolean(!this.showFailureAlert && this.graphData);
},
},
mounted() {
const { drawGraph, reportFailure } = this;
const { processGraphData, reportFailure } = this;
if (!this.graphUrl) {
reportFailure();
......@@ -36,30 +73,43 @@ export default {
axios
.get(this.graphUrl)
.then(response => {
drawGraph(response.data);
processGraphData(response.data);
})
.catch(reportFailure);
.catch(() => reportFailure(LOAD_FAILURE));
},
methods: {
drawGraph(data) {
return data;
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;
},
reportFailure() {
reportFailure(type) {
this.showFailureAlert = true;
this.failureType = type;
},
},
};
</script>
<template>
<div>
<gl-alert v-if="showFailureAlert" variant="danger" @dismiss="hideAlert">
{{ __('We are currently unable to fetch data for this graph.') }}
<gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert">
{{ failure.text }}
</gl-alert>
<div v-if="shouldDisplayGraph" data-testid="dag-graph-container">
<!-- graph goes here -->
</div>
<dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" />
</div>
</template>
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
import { PARSE_FAILURE } from './constants';
import { createSankey, getMaxNodes, removeOrphanNodes } from './utils';
export default {
viewOptions: {
baseHeight: 300,
baseWidth: 1000,
minNodeHeight: 60,
nodeWidth: 16,
nodePadding: 25,
paddingForLabels: 100,
labelMargin: 8,
// can plausibly applied through CSS instead, TBD
baseOpacity: 0.8,
highlightIn: 1,
highlightOut: 0.2,
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', this.createLinkPath)
.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})`)
);
},
appendLabelAsForeignObject(d, i, n) {
const currentNode = n[i];
const { height, wrapperWidth, width, x, y, textAlign } = this.labelPosition(d);
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) {
/*
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 clip = ({ y0, y1, source, target, width }) => {
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 */
};
return link
.append('clipPath')
.attr('id', d => {
return this.createAndAssignId(d, 'clipId', 'dag-clip');
})
.append('path')
.attr('d', clip);
},
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));
},
createLinkPath({ y0, y1, source, target, width }, idx) {
const { nodeWidth } = this.$options.viewOptions;
/*
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],
]);
},
createLinks(svg, linksData) {
const link = this.generateLinks(svg, linksData);
this.createGradient(link);
this.createClip(link);
this.appendLinks(link);
},
createNodes(svg, nodeData) {
this.generateNodes(svg, nodeData);
this.labelNodes(svg, nodeData);
},
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) {
const linkContainerName = 'dag-link';
return svg
.append('g')
.attr('fill', 'none')
.attr('stroke-opacity', this.$options.viewOptions.baseOpacity)
.selectAll(`.${linkContainerName}`)
.data(linksData)
.enter()
.append('g')
.attr('id', d => {
return this.createAndAssignId(d, 'uid', linkContainerName);
})
.classed(`${linkContainerName} gl-cursor-pointer`, true);
},
generateNodes(svg, nodeData) {
const nodeContainerName = 'dag-node';
const { nodeWidth } = this.$options.viewOptions;
return svg
.append('g')
.selectAll(`.${nodeContainerName}`)
.data(nodeData)
.enter()
.append('line')
.classed(`${nodeContainerName} gl-cursor-pointer`, true)
.attr('id', d => {
return this.createAndAssignId(d, 'uid', nodeContainerName);
})
.attr('stroke', this.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);
},
labelPosition({ x0, x1, y0, y1 }) {
const { paddingForLabels, labelMargin, nodePadding } = this.$options.viewOptions;
const firstCol = x0 <= paddingForLabels;
const lastCol = x1 >= this.width - paddingForLabels;
if (firstCol) {
return {
x: 0 + labelMargin,
y: y0,
height: `${y1 - y0}px`,
width: paddingForLabels - 2 * labelMargin,
textAlign: 'right',
};
}
if (lastCol) {
return {
x: this.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 < this.width / 2 ? 'left' : 'right',
};
},
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>
......@@ -141,7 +141,13 @@ export const parseData = data => {
values for the nodes and links in the graph.
*/
export const createSankey = ({ width, height, nodeWidth, nodePadding, paddingForLabels }) => {
export const createSankey = ({
width = 10,
height = 10,
nodeWidth = 10,
nodePadding = 10,
paddingForLabels = 1,
} = {}) => {
const sankeyGenerator = sankey()
.nodeId(({ name }) => name)
.nodeAlign(sankeyLeft)
......
......@@ -867,6 +867,9 @@ msgstr ""
msgid "A .NET Core console application template, customizable for any .NET Core project"
msgstr ""
msgid "A DAG must have two dependent jobs to be visualized on this tab."
msgstr ""
msgid "A GitBook site that uses Netlify for CI/CD instead of GitLab, but still with all the other great GitLab features."
msgstr ""
......@@ -2404,6 +2407,9 @@ msgstr ""
msgid "An unexpected error occurred while stopping the Web Terminal."
msgstr ""
msgid "An unknown error occurred while loading this graph."
msgstr ""
msgid "Analytics"
msgstr ""
......@@ -22070,6 +22076,9 @@ msgstr ""
msgid "There was an error loading users activity calendar."
msgstr ""
msgid "There was an error parsing the data for this graph."
msgstr ""
msgid "There was an error removing the e-mail."
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`The DAG graph in the basic case renders the graph svg 1`] = `
"<svg viewBox=\\"0,0,1000,540\\" width=\\"1000\\" height=\\"540\\">
<g fill=\\"none\\" stroke-opacity=\\"0.8\\">
<g id=\\"dag-link43\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad53\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\">
<stop offset=\\"0%\\" stop-color=\\"#e17223\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#83ab4a\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip63\\">
<path d=\\"
M100, 129
V158
H377.3333333333333
V100
H100
Z\\"></path>
</clipPath>
<path d=\\"M108,129L190,129L190,129L369.3333333333333,129\\" stroke=\\"url(#dag-grad53)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip63)\\"></path>
</g>
<g id=\\"dag-link44\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad54\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\">
<stop offset=\\"0%\\" stop-color=\\"#83ab4a\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#6f3500\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip64\\">
<path d=\\"
M361.3333333333333, 129.0000000000002
V158.0000000000002
H638.6666666666666
V100
H361.3333333333333
Z\\"></path>
</clipPath>
<path d=\\"M369.3333333333333,129L509.3333333333333,129L509.3333333333333,129.0000000000002L630.6666666666666,129.0000000000002\\" stroke=\\"url(#dag-grad54)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip64)\\"></path>
</g>
<g id=\\"dag-link45\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad55\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"622.6666666666666\\">
<stop offset=\\"0%\\" stop-color=\\"#5772ff\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#6f3500\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip65\\">
<path d=\\"
M100, 187.0000000000002
V241.00000000000003
H638.6666666666666
V158.0000000000002
H100
Z\\"></path>
</clipPath>
<path d=\\"M108,212.00000000000003L306,212.00000000000003L306,187.0000000000002L630.6666666666666,187.0000000000002\\" stroke=\\"url(#dag-grad55)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip65)\\"></path>
</g>
<g id=\\"dag-link46\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad56\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\">
<stop offset=\\"0%\\" stop-color=\\"#b24800\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#006887\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip66\\">
<path d=\\"
M100, 269.9999999999998
V324
H377.3333333333333
V240.99999999999977
H100
Z\\"></path>
</clipPath>
<path d=\\"M108,295L338.93333333333334,295L338.93333333333334,269.9999999999998L369.3333333333333,269.9999999999998\\" stroke=\\"url(#dag-grad56)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip66)\\"></path>
</g>
<g id=\\"dag-link47\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad57\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"116\\" x2=\\"361.3333333333333\\">
<stop offset=\\"0%\\" stop-color=\\"#25d2d2\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#487900\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip67\\">
<path d=\\"
M100, 352.99999999999994
V407.00000000000006
H377.3333333333333
V323.99999999999994
H100
Z\\"></path>
</clipPath>
<path d=\\"M108,378.00000000000006L144.66666666666669,378.00000000000006L144.66666666666669,352.99999999999994L369.3333333333333,352.99999999999994\\" stroke=\\"url(#dag-grad57)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip67)\\"></path>
</g>
<g id=\\"dag-link48\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad58\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\">
<stop offset=\\"0%\\" stop-color=\\"#006887\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#d84280\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip68\\">
<path d=\\"
M361.3333333333333, 270.0000000000001
V299.0000000000001
H638.6666666666666
V240.99999999999977
H361.3333333333333
Z\\"></path>
</clipPath>
<path d=\\"M369.3333333333333,269.9999999999998L464,269.9999999999998L464,270.0000000000001L630.6666666666666,270.0000000000001\\" stroke=\\"url(#dag-grad58)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip68)\\"></path>
</g>
<g id=\\"dag-link49\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad59\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\">
<stop offset=\\"0%\\" stop-color=\\"#487900\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#d84280\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip69\\">
<path d=\\"
M361.3333333333333, 328.0000000000001
V381.99999999999994
H638.6666666666666
V299.0000000000001
H361.3333333333333
Z\\"></path>
</clipPath>
<path d=\\"M369.3333333333333,352.99999999999994L522,352.99999999999994L522,328.0000000000001L630.6666666666666,328.0000000000001\\" stroke=\\"url(#dag-grad59)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip69)\\"></path>
</g>
<g id=\\"dag-link50\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad60\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"377.3333333333333\\" x2=\\"622.6666666666666\\">
<stop offset=\\"0%\\" stop-color=\\"#487900\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#3547de\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip70\\">
<path d=\\"
M361.3333333333333, 411
V440
H638.6666666666666
V381.99999999999994
H361.3333333333333
Z\\"></path>
</clipPath>
<path d=\\"M369.3333333333333,410.99999999999994L580,410.99999999999994L580,411L630.6666666666666,411\\" stroke=\\"url(#dag-grad60)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip70)\\"></path>
</g>
<g id=\\"dag-link51\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad61\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"638.6666666666666\\" x2=\\"884\\">
<stop offset=\\"0%\\" stop-color=\\"#d84280\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#006887\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip71\\">
<path d=\\"
M622.6666666666666, 270.1890725105691
V299.1890725105691
H900
V241.0000000000001
H622.6666666666666
Z\\"></path>
</clipPath>
<path d=\\"M630.6666666666666,270.0000000000001L861.6,270.0000000000001L861.6,270.1890725105691L892,270.1890725105691\\" stroke=\\"url(#dag-grad61)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip71)\\"></path>
</g>
<g id=\\"dag-link52\\" class=\\"dag-link gl-cursor-pointer\\">
<linearGradient id=\\"dag-grad62\\" gradientUnits=\\"userSpaceOnUse\\" x1=\\"638.6666666666666\\" x2=\\"884\\">
<stop offset=\\"0%\\" stop-color=\\"#3547de\\"></stop>
<stop offset=\\"100%\\" stop-color=\\"#275600\\"></stop>
</linearGradient>
<clipPath id=\\"dag-clip72\\">
<path d=\\"
M622.6666666666666, 411
V440
H900
V382
H622.6666666666666
Z\\"></path>
</clipPath>
<path d=\\"M630.6666666666666,411L679.9999999999999,411L679.9999999999999,411L892,411\\" stroke=\\"url(#dag-grad62)\\" style=\\"stroke-linejoin: round;\\" stroke-width=\\"56\\" clip-path=\\"url(#dag-clip72)\\"></path>
</g>
</g>
<g>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node73\\" stroke=\\"#e17223\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"104\\" y2=\\"154.00000000000003\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node74\\" stroke=\\"#83ab4a\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"104\\" y2=\\"154\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node75\\" stroke=\\"#5772ff\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"187.00000000000003\\" y2=\\"237.00000000000003\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node76\\" stroke=\\"#b24800\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"270\\" y2=\\"320.00000000000006\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node77\\" stroke=\\"#25d2d2\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"108\\" x2=\\"108\\" y1=\\"353.00000000000006\\" y2=\\"403.0000000000001\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node78\\" stroke=\\"#6f3500\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"104.0000000000002\\" y2=\\"212.00000000000009\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node79\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"244.99999999999977\\" y2=\\"294.99999999999994\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node80\\" stroke=\\"#487900\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"369\\" x2=\\"369\\" y1=\\"327.99999999999994\\" y2=\\"436\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node81\\" stroke=\\"#d84280\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"245.00000000000009\\" y2=\\"353\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node82\\" stroke=\\"#3547de\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"630\\" x2=\\"630\\" y1=\\"386\\" y2=\\"436\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node83\\" stroke=\\"#006887\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"245.18907251056908\\" y2=\\"295.1890725105691\\"></line>
<line class=\\"dag-node gl-cursor-pointer\\" id=\\"dag-node84\\" stroke=\\"#275600\\" stroke-width=\\"16\\" stroke-linecap=\\"round\\" x1=\\"892\\" x2=\\"892\\" y1=\\"386\\" y2=\\"436\\"></line>
</g>
<g class=\\"gl-font-sm\\">
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58.00000000000003px\\" width=\\"84\\" x=\\"8\\" y=\\"100\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58.00000000000003px; text-align: right;\\">build_a</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"369.3333333333333\\" y=\\"75\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: left;\\">test_a</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58px\\" width=\\"84\\" x=\\"8\\" y=\\"183.00000000000003\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58px; text-align: right;\\">test_b</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58.00000000000006px\\" width=\\"84\\" x=\\"8\\" y=\\"266\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58.00000000000006px; text-align: right;\\">post_test_a</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58.00000000000006px\\" width=\\"84\\" x=\\"8\\" y=\\"349.00000000000006\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58.00000000000006px; text-align: right;\\">post_test_b</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"630.6666666666666\\" y=\\"75.0000000000002\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: right;\\">post_test_c</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"369.3333333333333\\" y=\\"215.99999999999977\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: left;\\">staging_a</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"369.3333333333333\\" y=\\"298.99999999999994\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: left;\\">staging_b</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"630.6666666666666\\" y=\\"216.00000000000009\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: right;\\">canary_a</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"25px\\" width=\\"84\\" x=\\"630.6666666666666\\" y=\\"357\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 25px; text-align: right;\\">canary_c</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58px\\" width=\\"84\\" x=\\"908\\" y=\\"241.18907251056908\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58px; text-align: left;\\">production_a</div>
</foreignObject>
<foreignObject requiredFeatures=\\"http://www.w3.org/TR/SVG11/feature#Extensibility\\" height=\\"58px\\" width=\\"84\\" x=\\"908\\" y=\\"382\\" class=\\"gl-overflow-visible\\">
<div class=\\"gl-display-flex gl-pointer-events-none gl-flex-direction-column gl-justify-content-center gl-overflow-wrap-break\\" style=\\"height: 58px; text-align: left;\\">production_d</div>
</foreignObject>
</g>
</svg>"
`;
import { mount } from '@vue/test-utils';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import { createSankey, removeOrphanNodes } from '~/pipelines/components/dag/utils';
import { parsedData } from './mock_data';
describe('The DAG graph', () => {
let wrapper;
const getGraph = () => wrapper.find('.dag-graph-container > svg');
const getAllLinks = () => wrapper.findAll('.dag-link');
const getAllNodes = () => wrapper.findAll('.dag-node');
const getAllLabels = () => wrapper.findAll('foreignObject');
const createComponent = (propsData = {}) => {
if (wrapper?.destroy) {
wrapper.destroy();
}
wrapper = mount(DagGraph, {
attachToDocument: true,
propsData,
data() {
return {
color: () => {},
width: 0,
height: 0,
};
},
});
};
beforeEach(() => {
createComponent({ graphData: parsedData });
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('in the basic case', () => {
beforeEach(() => {
/*
The graph uses random to offset links. To keep the snapshot consistent,
we mock Math.random. Wheeeee!
*/
const randomNumber = jest.spyOn(global.Math, 'random');
randomNumber.mockImplementation(() => 0.2);
createComponent({ graphData: parsedData });
});
it('renders the graph svg', () => {
expect(getGraph().exists()).toBe(true);
expect(getGraph().html()).toMatchSnapshot();
});
});
describe('links', () => {
it('renders the expected number of links', () => {
expect(getAllLinks()).toHaveLength(parsedData.links.length);
});
it('renders the expected number of gradients', () => {
expect(wrapper.findAll('linearGradient')).toHaveLength(parsedData.links.length);
});
it('renders the expected number of clip paths', () => {
expect(wrapper.findAll('clipPath')).toHaveLength(parsedData.links.length);
});
});
describe('nodes and labels', () => {
const sankeyNodes = createSankey()(parsedData).nodes;
const processedNodes = removeOrphanNodes(sankeyNodes);
describe('nodes', () => {
it('renders the expected number of nodes', () => {
expect(getAllNodes()).toHaveLength(processedNodes.length);
});
});
describe('labels', () => {
it('renders the expected number of labels as foreignObjects', () => {
expect(getAllLabels()).toHaveLength(processedNodes.length);
});
it('renders the title as text', () => {
expect(
getAllLabels()
.at(0)
.text(),
).toBe(parsedData.nodes[0].name);
});
});
});
});
import { mount } from '@vue/test-utils';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import waitForPromises from 'helpers/wait_for_promises';
import { GlAlert } from '@gitlab/ui';
import Dag from '~/pipelines/components/dag/dag.vue';
import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
describe('Pipeline DAG graph', () => {
import {
DEFAULT,
PARSE_FAILURE,
LOAD_FAILURE,
UNSUPPORTED_DATA,
} from '~/pipelines/components/dag//constants';
import { mockBaseData, tooSmallGraph, unparseableGraph } from './mock_data';
describe('Pipeline DAG graph wrapper', () => {
let wrapper;
let axiosMock;
let mock;
const getAlert = () => wrapper.find(GlAlert);
const getGraph = () => wrapper.find('[data-testid="dag-graph-container"]');
const dataPath = 'root/test/pipelines/90/dag.json';
const getGraph = () => wrapper.find(DagGraph);
const getErrorText = type => wrapper.vm.$options.errorTexts[type];
const createComponent = (propsData = {}, method = mount) => {
axiosMock = new MockAdapter(axios);
const dataPath = '/root/test/pipelines/90/dag.json';
const createComponent = (propsData = {}) => {
if (wrapper?.destroy) {
wrapper.destroy();
}
wrapper = method(Dag, {
wrapper = shallowMount(Dag, {
propsData,
data() {
return {
......@@ -30,8 +38,12 @@ describe('Pipeline DAG graph', () => {
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
axiosMock.restore();
mock.restore();
wrapper.destroy();
wrapper = null;
});
......@@ -41,34 +53,80 @@ describe('Pipeline DAG graph', () => {
createComponent({ graphUrl: undefined });
});
it('shows the alert and not the graph', () => {
it('shows the DEFAULT alert and not the graph', () => {
expect(getAlert().exists()).toBe(true);
expect(getAlert().text()).toBe(getErrorText(DEFAULT));
expect(getGraph().exists()).toBe(false);
});
});
describe('when there is a dataUrl', () => {
beforeEach(() => {
createComponent({ graphUrl: dataPath });
describe('but the data fetch fails', () => {
beforeEach(() => {
mock.onGet(dataPath).replyOnce(500);
createComponent({ graphUrl: dataPath });
});
it('shows the LOAD_FAILURE alert and not the graph', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getAlert().exists()).toBe(true);
expect(getAlert().text()).toBe(getErrorText(LOAD_FAILURE));
expect(getGraph().exists()).toBe(false);
});
});
});
it('shows the graph and not the alert', () => {
expect(getAlert().exists()).toBe(false);
expect(getGraph().exists()).toBe(true);
describe('the data fetch succeeds but the parse fails', () => {
beforeEach(() => {
mock.onGet(dataPath).replyOnce(200, unparseableGraph);
createComponent({ graphUrl: dataPath });
});
it('shows the PARSE_FAILURE alert and not the graph', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getAlert().exists()).toBe(true);
expect(getAlert().text()).toBe(getErrorText(PARSE_FAILURE));
expect(getGraph().exists()).toBe(false);
});
});
});
describe('but the data fetch fails', () => {
describe('and the data fetch and parse succeeds', () => {
beforeEach(() => {
mock.onGet(dataPath).replyOnce(200, mockBaseData);
createComponent({ graphUrl: dataPath });
});
it('shows the graph and not the alert', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getAlert().exists()).toBe(false);
expect(getGraph().exists()).toBe(true);
});
});
});
describe('the data fetch and parse succeeds, but the resulting graph is too small', () => {
beforeEach(() => {
axiosMock.onGet(dataPath).replyOnce(500);
mock.onGet(dataPath).replyOnce(200, tooSmallGraph);
createComponent({ graphUrl: dataPath });
});
it('shows the alert and not the graph', () => {
it('shows the UNSUPPORTED_DATA alert and not the graph', () => {
return wrapper.vm
.$nextTick()
.then(waitForPromises)
.then(() => {
expect(getAlert().exists()).toBe(true);
expect(getAlert().text()).toBe(getErrorText(UNSUPPORTED_DATA));
expect(getGraph().exists()).toBe(false);
});
});
......
......@@ -3,7 +3,7 @@
as well as non-parallel jobs with spaces in the name to prevent
us relying on spaces as an indicator.
*/
export default {
export const mockBaseData = {
stages: [
{
name: 'test',
......@@ -42,3 +42,349 @@ export default {
},
],
};
export const tooSmallGraph = {
stages: [
{
name: 'test',
groups: [
{
name: 'jest',
size: 2,
jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }],
},
{
name: 'rspec',
size: 1,
jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }],
},
],
},
{
name: 'fixtures',
groups: [
{
name: 'frontend fixtures',
size: 1,
jobs: [{ name: 'frontend fixtures' }],
},
],
},
{
name: 'un-needed',
groups: [
{
name: 'un-needed',
size: 1,
jobs: [{ name: 'un-needed' }],
},
],
},
],
};
export const unparseableGraph = [
{
name: 'test',
groups: [
{
name: 'jest',
size: 2,
jobs: [{ name: 'jest 1/2', needs: ['frontend fixtures'] }, { name: 'jest 2/2' }],
},
{
name: 'rspec',
size: 1,
jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }],
},
],
},
{
name: 'un-needed',
groups: [
{
name: 'un-needed',
size: 1,
jobs: [{ name: 'un-needed' }],
},
],
},
];
/*
This represents data that has been parsed by the wrapper
*/
export const parsedData = {
nodes: [
{
name: 'build_a',
size: 1,
jobs: [
{
name: 'build_a',
},
],
category: 'build',
},
{
name: 'build_b',
size: 1,
jobs: [
{
name: 'build_b',
},
],
category: 'build',
},
{
name: 'test_a',
size: 1,
jobs: [
{
name: 'test_a',
needs: ['build_a'],
},
],
category: 'test',
},
{
name: 'test_b',
size: 1,
jobs: [
{
name: 'test_b',
},
],
category: 'test',
},
{
name: 'test_c',
size: 1,
jobs: [
{
name: 'test_c',
},
],
category: 'test',
},
{
name: 'test_d',
size: 1,
jobs: [
{
name: 'test_d',
},
],
category: 'test',
},
{
name: 'post_test_a',
size: 1,
jobs: [
{
name: 'post_test_a',
},
],
category: 'post-test',
},
{
name: 'post_test_b',
size: 1,
jobs: [
{
name: 'post_test_b',
},
],
category: 'post-test',
},
{
name: 'post_test_c',
size: 1,
jobs: [
{
name: 'post_test_c',
needs: ['test_a', 'test_b'],
},
],
category: 'post-test',
},
{
name: 'staging_a',
size: 1,
jobs: [
{
name: 'staging_a',
needs: ['post_test_a'],
},
],
category: 'staging',
},
{
name: 'staging_b',
size: 1,
jobs: [
{
name: 'staging_b',
needs: ['post_test_b'],
},
],
category: 'staging',
},
{
name: 'staging_c',
size: 1,
jobs: [
{
name: 'staging_c',
},
],
category: 'staging',
},
{
name: 'staging_d',
size: 1,
jobs: [
{
name: 'staging_d',
},
],
category: 'staging',
},
{
name: 'staging_e',
size: 1,
jobs: [
{
name: 'staging_e',
},
],
category: 'staging',
},
{
name: 'canary_a',
size: 1,
jobs: [
{
name: 'canary_a',
needs: ['staging_a', 'staging_b'],
},
],
category: 'canary',
},
{
name: 'canary_b',
size: 1,
jobs: [
{
name: 'canary_b',
},
],
category: 'canary',
},
{
name: 'canary_c',
size: 1,
jobs: [
{
name: 'canary_c',
needs: ['staging_b'],
},
],
category: 'canary',
},
{
name: 'production_a',
size: 1,
jobs: [
{
name: 'production_a',
needs: ['canary_a'],
},
],
category: 'production',
},
{
name: 'production_b',
size: 1,
jobs: [
{
name: 'production_b',
},
],
category: 'production',
},
{
name: 'production_c',
size: 1,
jobs: [
{
name: 'production_c',
},
],
category: 'production',
},
{
name: 'production_d',
size: 1,
jobs: [
{
name: 'production_d',
needs: ['canary_c'],
},
],
category: 'production',
},
],
links: [
{
source: 'build_a',
target: 'test_a',
value: 10,
},
{
source: 'test_a',
target: 'post_test_c',
value: 10,
},
{
source: 'test_b',
target: 'post_test_c',
value: 10,
},
{
source: 'post_test_a',
target: 'staging_a',
value: 10,
},
{
source: 'post_test_b',
target: 'staging_b',
value: 10,
},
{
source: 'staging_a',
target: 'canary_a',
value: 10,
},
{
source: 'staging_b',
target: 'canary_a',
value: 10,
},
{
source: 'staging_b',
target: 'canary_c',
value: 10,
},
{
source: 'canary_a',
target: 'production_a',
value: 10,
},
{
source: 'canary_c',
target: 'production_d',
value: 10,
},
],
};
......@@ -8,12 +8,12 @@ import {
getMaxNodes,
} from '~/pipelines/components/dag/utils';
import mockGraphData from './mock_data';
import { mockBaseData } from './mock_data';
describe('DAG visualization parsing utilities', () => {
const { nodes, nodeDict } = createNodesStructure(mockGraphData.stages);
const { nodes, nodeDict } = createNodesStructure(mockBaseData.stages);
const unfilteredLinks = makeLinksFromNodes(nodes, nodeDict);
const parsed = parseData(mockGraphData.stages);
const parsed = parseData(mockBaseData.stages);
const layoutSettings = {
width: 200,
......@@ -30,10 +30,10 @@ describe('DAG visualization parsing utilities', () => {
const parallelJobName = 'jest 1/2';
const singleJobName = 'frontend fixtures';
const { name, jobs, size } = mockGraphData.stages[0].groups[0];
const { name, jobs, size } = mockBaseData.stages[0].groups[0];
it('returns the expected node structure', () => {
expect(nodes[0]).toHaveProperty('category', mockGraphData.stages[0].name);
expect(nodes[0]).toHaveProperty('category', mockBaseData.stages[0].name);
expect(nodes[0]).toHaveProperty('name', name);
expect(nodes[0]).toHaveProperty('jobs', jobs);
expect(nodes[0]).toHaveProperty('size', size);
......
......@@ -3551,10 +3551,10 @@ d3-zoom@1:
d3-selection "1"
d3-transition "1"
d3@^5.14, d3@^5.7.0:
version "5.15.0"
resolved "https://registry.yarnpkg.com/d3/-/d3-5.15.0.tgz#ffd44958e6a3cb8a59a84429c45429b8bca5677a"
integrity sha512-C+E80SL2nLLtmykZ6klwYj5rPqB5nlfN5LdWEAVdWPppqTD8taoJi2PxLZjPeYT8FFRR2yucXq+kBlOnnvZeLg==
d3@^5.14, d3@^5.16.0, d3@^5.7.0:
version "5.16.0"
resolved "https://registry.yarnpkg.com/d3/-/d3-5.16.0.tgz#9c5e8d3b56403c79d4ed42fbd62f6113f199c877"
integrity sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==
dependencies:
d3-array "1"
d3-axis "1"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment