diff options
Diffstat (limited to 'app/assets/javascripts/pipelines/components/dag')
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) => { |