summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/pipelines/components/dag
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/pipelines/components/dag')
-rw-r--r--app/assets/javascripts/pipelines/components/dag/constants.js5
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag.vue117
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_annotations.vue73
-rw-r--r--app/assets/javascripts/pipelines/components/dag/dag_graph.vue70
-rw-r--r--app/assets/javascripts/pipelines/components/dag/interactions.js50
5 files changed, 276 insertions, 39 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/constants.js b/app/assets/javascripts/pipelines/components/dag/constants.js
index 51b1fb4f4cc..b6a98fdc488 100644
--- a/app/assets/javascripts/pipelines/components/dag/constants.js
+++ b/app/assets/javascripts/pipelines/components/dag/constants.js
@@ -8,3 +8,8 @@ export const DEFAULT = 'default';
export const IS_HIGHLIGHTED = 'dag-highlighted';
export const LINK_SELECTOR = 'dag-link';
export const NODE_SELECTOR = 'dag-node';
+
+/* Annotation types */
+export const ADD_NOTE = 'add';
+export const REMOVE_NOTE = 'remove';
+export const REPLACE_NOTES = 'replace';
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue
index 6e0d23ef87f..85163a666e2 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag.vue
@@ -1,19 +1,32 @@
<script>
-import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { isEmpty } from 'lodash';
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 DagAnnotations from './dag_annotations.vue';
+import {
+ DEFAULT,
+ PARSE_FAILURE,
+ LOAD_FAILURE,
+ UNSUPPORTED_DATA,
+ ADD_NOTE,
+ REMOVE_NOTE,
+ REPLACE_NOTES,
+} from './constants';
import { parseData } from './parsing_utils';
export default {
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'Dag',
components: {
+ DagAnnotations,
DagGraph,
GlAlert,
GlLink,
GlSprintf,
+ GlEmptyState,
+ GlButton,
},
props: {
graphUrl: {
@@ -21,21 +34,43 @@ export default {
required: false,
default: '',
},
+ emptySvgPath: {
+ type: String,
+ required: true,
+ default: '',
+ },
+ dagDocPath: {
+ type: String,
+ required: true,
+ default: '',
+ },
},
data() {
return {
- showFailureAlert: false,
- showBetaInfo: true,
+ annotationsMap: {},
failureType: null,
graphData: null,
+ showFailureAlert: false,
+ showBetaInfo: true,
+ hasNoDependentJobs: false,
};
},
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.'),
+ [UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'),
[DEFAULT]: __('An unknown error occurred while loading this graph.'),
},
+ emptyStateTexts: {
+ title: __('Start using Directed Acyclic Graphs (DAG)'),
+ firstDescription: __(
+ "This pipeline does not use the %{codeStart}needs%{codeEnd} keyword and can't be represented as a directed acyclic graph.",
+ ),
+ secondDescription: __(
+ 'Using %{codeStart}needs%{codeEnd} allows jobs to run before their stage is reached, as soon as their individual dependencies are met, which speeds up your pipelines.',
+ ),
+ button: __('Learn more about job dependencies'),
+ },
computed: {
betaMessage() {
return __(
@@ -66,6 +101,9 @@ export default {
};
}
},
+ shouldDisplayAnnotations() {
+ return !isEmpty(this.annotationsMap);
+ },
shouldDisplayGraph() {
return Boolean(!this.showFailureAlert && this.graphData);
},
@@ -86,6 +124,9 @@ export default {
.catch(() => reportFailure(LOAD_FAILURE));
},
methods: {
+ addAnnotationToMap({ uid, source, target }) {
+ this.$set(this.annotationsMap, uid, { source, target });
+ },
processGraphData(data) {
let parsed;
@@ -96,11 +137,18 @@ export default {
return;
}
- if (parsed.links.length < 2) {
+ if (parsed.links.length === 1) {
this.reportFailure(UNSUPPORTED_DATA);
return;
}
+ // If there are no links, we don't report failure
+ // as it simply means the user does not use job dependencies
+ if (parsed.links.length === 0) {
+ this.hasNoDependentJobs = true;
+ return;
+ }
+
this.graphData = parsed;
},
hideAlert() {
@@ -109,10 +157,28 @@ export default {
hideBetaInfo() {
this.showBetaInfo = false;
},
+ removeAnnotationFromMap({ uid }) {
+ this.$delete(this.annotationsMap, uid);
+ },
reportFailure(type) {
this.showFailureAlert = true;
this.failureType = type;
},
+ updateAnnotation({ type, data }) {
+ switch (type) {
+ case ADD_NOTE:
+ this.addAnnotationToMap(data);
+ break;
+ case REMOVE_NOTE:
+ this.removeAnnotationFromMap(data);
+ break;
+ case REPLACE_NOTES:
+ this.annotationsMap = data;
+ break;
+ default:
+ break;
+ }
+ },
},
};
</script>
@@ -131,6 +197,43 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
- <dag-graph v-if="shouldDisplayGraph" :graph-data="graphData" @onFailure="reportFailure" />
+ <div class="gl-relative">
+ <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" />
+ <dag-graph
+ v-if="shouldDisplayGraph"
+ :graph-data="graphData"
+ @onFailure="reportFailure"
+ @update-annotation="updateAnnotation"
+ />
+ <gl-empty-state
+ v-else-if="hasNoDependentJobs"
+ :svg-path="emptySvgPath"
+ :title="$options.emptyStateTexts.title"
+ >
+ <template #description>
+ <div class="gl-text-left">
+ <p>
+ <gl-sprintf :message="$options.emptyStateTexts.firstDescription">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <p>
+ <gl-sprintf :message="$options.emptyStateTexts.secondDescription">
+ <template #code="{ content }">
+ <code>{{ content }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ </div>
+ </template>
+ <template #actions>
+ <gl-button :href="dagDocPath" target="__blank" variant="success">
+ {{ $options.emptyStateTexts.button }}
+ </gl-button>
+ </template>
+ </gl-empty-state>
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue
new file mode 100644
index 00000000000..a1500166cdc
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/dag/dag_annotations.vue
@@ -0,0 +1,73 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'DagAnnotations',
+ components: {
+ GlButton,
+ },
+ props: {
+ annotations: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showList: true,
+ };
+ },
+ computed: {
+ linkText() {
+ return this.showList ? __('Hide list') : __('Show list');
+ },
+ shouldShowLink() {
+ return Object.keys(this.annotations).length > 1;
+ },
+ wrapperClasses() {
+ return [
+ 'gl-display-flex',
+ 'gl-flex-direction-column',
+ 'gl-fixed',
+ 'gl-right-1',
+ 'gl-top-66vh',
+ 'gl-w-max-content',
+ 'gl-px-5',
+ 'gl-py-4',
+ 'gl-rounded-base',
+ 'gl-bg-white',
+ ].join(' ');
+ },
+ },
+ methods: {
+ toggleList() {
+ this.showList = !this.showList;
+ },
+ },
+};
+</script>
+<template>
+ <div :class="wrapperClasses">
+ <div v-if="showList">
+ <div
+ v-for="note in annotations"
+ :key="note.uid"
+ class="gl-display-flex gl-align-items-center"
+ >
+ <div
+ data-testid="dag-color-block"
+ class="gl-w-6 gl-h-5"
+ :style="{
+ background: `linear-gradient(0.25turn, ${note.source.color} 40%, ${note.target.color} 60%)`,
+ }"
+ ></div>
+ <div data-testid="dag-note-text" class="gl-px-2 gl-font-base gl-align-items-center">
+ {{ note.source.name }} → {{ note.target.name }}
+ </div>
+ </div>
+ </div>
+
+ <gl-button v-if="shouldShowLink" variant="link" @click="toggleList">{{ linkText }}</gl-button>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
index 063ec091e4d..d12baa9617e 100644
--- a/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
+++ b/app/assets/javascripts/pipelines/components/dag/dag_graph.vue
@@ -1,8 +1,17 @@
<script>
import * as d3 from 'd3';
import { uniqueId } from 'lodash';
-import { LINK_SELECTOR, NODE_SELECTOR, PARSE_FAILURE } from './constants';
import {
+ LINK_SELECTOR,
+ NODE_SELECTOR,
+ PARSE_FAILURE,
+ ADD_NOTE,
+ REMOVE_NOTE,
+ REPLACE_NOTES,
+} from './constants';
+import {
+ currentIsLive,
+ getLiveLinksAsDict,
highlightLinks,
restoreLinks,
toggleLinkHighlight,
@@ -25,6 +34,11 @@ export default {
containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join(
' ',
),
+ hoverFadeClasses: [
+ 'gl-cursor-pointer',
+ 'gl-transition-duration-slow',
+ 'gl-transition-timing-function-ease',
+ ].join(' '),
},
gitLabColorRotation: [
'#e17223',
@@ -50,8 +64,8 @@ export default {
data() {
return {
color: () => {},
- width: 0,
height: 0,
+ width: 0,
};
},
mounted() {
@@ -60,7 +74,7 @@ export default {
try {
countedAndTransformed = this.transformData(this.graphData);
} catch {
- this.$emit('onFailure', PARSE_FAILURE);
+ this.$emit('on-failure', PARSE_FAILURE);
return;
}
@@ -90,17 +104,33 @@ export default {
},
appendLinkInteractions(link) {
+ const { baseOpacity } = this.$options.viewOptions;
return link
- .on('mouseover', highlightLinks)
- .on('mouseout', restoreLinks.bind(null, this.$options.viewOptions.baseOpacity))
- .on('click', toggleLinkHighlight.bind(null, this.$options.viewOptions.baseOpacity));
+ .on('mouseover', (d, idx, collection) => {
+ if (currentIsLive(idx, collection)) {
+ return;
+ }
+ this.$emit('update-annotation', { type: ADD_NOTE, data: d });
+ highlightLinks(d, idx, collection);
+ })
+ .on('mouseout', (d, idx, collection) => {
+ if (currentIsLive(idx, collection)) {
+ return;
+ }
+ this.$emit('update-annotation', { type: REMOVE_NOTE, data: d });
+ restoreLinks(baseOpacity);
+ })
+ .on('click', (d, idx, collection) => {
+ toggleLinkHighlight(baseOpacity, d, idx, collection);
+ this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() });
+ });
},
appendNodeInteractions(node) {
- return node.on(
- 'click',
- togglePathHighlights.bind(null, this.$options.viewOptions.baseOpacity),
- );
+ return node.on('click', (d, idx, collection) => {
+ togglePathHighlights(this.$options.viewOptions.baseOpacity, d, idx, collection);
+ this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() });
+ });
},
appendLabelAsForeignObject(d, i, n) {
@@ -230,7 +260,10 @@ export default {
.attr('id', d => {
return this.createAndAssignId(d, 'uid', LINK_SELECTOR);
})
- .classed(`${LINK_SELECTOR} gl-cursor-pointer`, true);
+ .classed(
+ `${LINK_SELECTOR} gl-transition-property-stroke-opacity ${this.$options.viewOptions.hoverFadeClasses}`,
+ true,
+ );
},
generateNodes(svg, nodeData) {
@@ -242,7 +275,10 @@ export default {
.data(nodeData)
.enter()
.append('line')
- .classed(`${NODE_SELECTOR} gl-cursor-pointer`, true)
+ .classed(
+ `${NODE_SELECTOR} gl-transition-property-stroke ${this.$options.viewOptions.hoverFadeClasses}`,
+ true,
+ )
.attr('id', d => {
return this.createAndAssignId(d, 'uid', NODE_SELECTOR);
})
@@ -260,6 +296,11 @@ export default {
.attr('y2', d => d.y1 - 4);
},
+ initColors() {
+ const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation);
+ return ({ name }) => colorFn(name);
+ },
+
labelNodes(svg, nodeData) {
return svg
.append('g')
@@ -271,11 +312,6 @@ export default {
.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);
diff --git a/app/assets/javascripts/pipelines/components/dag/interactions.js b/app/assets/javascripts/pipelines/components/dag/interactions.js
index c9008730c90..e9f3e9f0e2c 100644
--- a/app/assets/javascripts/pipelines/components/dag/interactions.js
+++ b/app/assets/javascripts/pipelines/components/dag/interactions.js
@@ -5,10 +5,20 @@ 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 getLiveLinks = () => d3.selectAll(`.${LINK_SELECTOR}.${IS_HIGHLIGHTED}`);
const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`);
+export const getLiveLinksAsDict = () => {
+ return Object.fromEntries(
+ getLiveLinks()
+ .data()
+ .map(d => [d.uid, d]),
+ );
+};
+export const currentIsLive = (idx, collection) =>
+ getCurrent(idx, collection).classed(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);
@@ -16,10 +26,10 @@ 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 => {
+export const getAllLinkAncestors = node => {
if (node.targetLinks) {
return node.targetLinks.flatMap(n => {
- return [n.uid, ...getAllLinkAncestors(n.source)];
+ return [n, ...getAllLinkAncestors(n.source)];
});
}
@@ -59,8 +69,8 @@ const highlightPath = (parentLinks, parentNodes) => {
backgroundNodes(getNodesNotLive());
/* highlight correct links */
- parentLinks.forEach(id => {
- foregroundLinks(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true);
+ parentLinks.forEach(({ uid }) => {
+ foregroundLinks(d3.select(`#${uid}`)).classed(IS_HIGHLIGHTED, true);
});
/* highlight correct nodes */
@@ -69,9 +79,22 @@ const highlightPath = (parentLinks, parentNodes) => {
});
};
+const restoreNodes = () => {
+ /*
+ When paths are unclicked, they can take down nodes that
+ are still in use for other paths. This checks the live paths and
+ rehighlights their nodes.
+ */
+
+ getLiveLinks().each(d => {
+ foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true);
+ foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true);
+ });
+};
+
const restorePath = (parentLinks, parentNodes, baseOpacity) => {
- parentLinks.forEach(id => {
- renewLinks(d3.select(`#${id}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
+ parentLinks.forEach(({ uid }) => {
+ renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false);
});
parentNodes.forEach(id => {
@@ -86,14 +109,10 @@ const restorePath = (parentLinks, parentNodes, baseOpacity) => {
backgroundLinks(getOtherLinks());
backgroundNodes(getNodesNotLive());
+ restoreNodes();
};
-export const restoreLinks = (baseOpacity, d, idx, collection) => {
- /* in this case, it has just been clicked */
- if (currentIsLive(idx, collection)) {
- return;
- }
-
+export const restoreLinks = baseOpacity => {
/*
if there exist live links, reset to highlight out / pale
otherwise, reset to base
@@ -111,11 +130,12 @@ export const restoreLinks = (baseOpacity, d, idx, collection) => {
export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => {
if (currentIsLive(idx, collection)) {
- restorePath([d.uid], [d.source.uid, d.target.uid], baseOpacity);
+ restorePath([d], [d.source.uid, d.target.uid], baseOpacity);
+ restoreNodes();
return;
}
- highlightPath([d.uid], [d.source.uid, d.target.uid]);
+ highlightPath([d], [d.source.uid, d.target.uid]);
};
export const togglePathHighlights = (baseOpacity, d, idx, collection) => {