Commit 3128b799 authored by Frédéric Caplette's avatar Frédéric Caplette Committed by Sarah Groff Hennigh-Palermo

Implement the drawing algorithm for yaml viz

Adds a d3 implementation that takes all the
yaml visualization nodes and trace lines
between those who are needed.
parent 11160924
...@@ -7,7 +7,7 @@ import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql' ...@@ -7,7 +7,7 @@ import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'
import DagGraph from './dag_graph.vue'; import DagGraph from './dag_graph.vue';
import DagAnnotations from './dag_annotations.vue'; import DagAnnotations from './dag_annotations.vue';
import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants';
import { parseData } from './parsing_utils'; import { parseData } from '../parsing_utils';
import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants'; import { DEFAULT, PARSE_FAILURE, LOAD_FAILURE, UNSUPPORTED_DATA } from '../../constants';
export default { export default {
......
...@@ -10,7 +10,7 @@ import { ...@@ -10,7 +10,7 @@ import {
toggleLinkHighlight, toggleLinkHighlight,
togglePathHighlights, togglePathHighlights,
} from './interactions'; } from './interactions';
import { getMaxNodes, removeOrphanNodes } from './parsing_utils'; import { getMaxNodes, removeOrphanNodes } from '../parsing_utils';
import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils'; import { calculateClip, createLinkPath, createSankey, labelPosition } from './drawing_utils';
import { PARSE_FAILURE } from '../../constants'; import { PARSE_FAILURE } from '../../constants';
......
import * as d3 from 'd3';
import { createUniqueJobId } from '../../utils';
/**
* This function expects its first argument data structure
* to be the same shaped as the one generated by `parseData`,
* which contains nodes and links. For each link,
* we find the nodes in the graph, calculate their coordinates and
* trace the lines that represent the needs of each job.
* @param {Object} nodeDict - Resulting object of `parseData` with nodes and links
* @param {Object} jobs - An object where each key is the job name that contains the job data
* @param {ref} svg - Reference to the svg we draw in
* @returns {Array} Links that contain all the information about them
*/
export const generateLinksData = ({ links }, jobs, containerID) => {
const containerEl = document.getElementById(containerID);
return links.map(link => {
const path = d3.path();
// We can only have one unique job name per stage, so our selector
// is: ${stageName}-${jobName}
const sourceId = createUniqueJobId(jobs[link.source].stage, link.source);
const targetId = createUniqueJobId(jobs[link.target].stage, link.target);
const sourceNodeEl = document.getElementById(sourceId);
const targetNodeEl = document.getElementById(targetId);
const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect();
const targetNodeCoordinates = targetNodeEl.getBoundingClientRect();
const containerCoordinates = containerEl.getBoundingClientRect();
// Because we add the svg dynamically and calculate the coordinates
// with plain JS and not D3, we need to account for the fact that
// the coordinates we are getting are absolutes, but we want to draw
// relative to the svg container, which starts at `containerCoordinates(x,y)`
// so we substract these from the total. We also need to remove the padding
// from the total to make sure it's aligned properly. We then make the line
// positioned in the center of the job node by adding half the height
// of the job pill.
const paddingLeft = Number(
window
.getComputedStyle(containerEl, null)
.getPropertyValue('padding-left')
.replace('px', ''),
);
const paddingTop = Number(
window
.getComputedStyle(containerEl, null)
.getPropertyValue('padding-top')
.replace('px', ''),
);
const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft;
const sourceNodeY =
sourceNodeCoordinates.top -
containerCoordinates.y -
paddingTop +
sourceNodeCoordinates.height / 2;
const targetNodeX = targetNodeCoordinates.x - containerCoordinates.x - paddingLeft;
const targetNodeY =
targetNodeCoordinates.y -
containerCoordinates.y -
paddingTop +
sourceNodeCoordinates.height / 2;
// Start point
path.moveTo(sourceNodeX, sourceNodeY);
// Add bezier curve. The first 4 coordinates are the 2 control
// points to create the curve, and the last one is the end point (x, y).
// We want our control points to be in the middle of the line
const controlPointX = sourceNodeX + (targetNodeX - sourceNodeX) / 2;
path.bezierCurveTo(
controlPointX,
sourceNodeY,
controlPointX,
targetNodeY,
targetNodeX,
targetNodeY,
);
return { ...link, path: path.toString() };
});
};
...@@ -10,13 +10,18 @@ export default { ...@@ -10,13 +10,18 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
jobId: {
type: String,
required: true,
},
}, },
}; };
</script> </script>
<template> <template>
<tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top">
<div <div
class="gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-inset-border-1-green-600 gl-mb-3 gl-px-5 gl-py-2 pipeline-job-pill " :id="jobId"
class="gl-bg-white gl-text-center gl-text-truncate gl-rounded-pill gl-inset-border-1-green-600 gl-mb-3 gl-px-5 gl-py-2 gl-relative gl-z-index-1 pipeline-job-pill "
> >
{{ jobName }} {{ jobName }}
</div> </div>
......
<script> <script>
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
import JobPill from './job_pill.vue'; import JobPill from './job_pill.vue';
import StagePill from './stage_pill.vue'; import StagePill from './stage_pill.vue';
import { generateLinksData } from './drawing_utils';
import { parseData } from '../parsing_utils';
import { DRAW_FAILURE, DEFAULT } from '../../constants';
import { createUniqueJobId } from '../../utils';
export default { export default {
components: { components: {
...@@ -10,28 +15,112 @@ export default { ...@@ -10,28 +15,112 @@ export default {
JobPill, JobPill,
StagePill, StagePill,
}, },
CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF',
CONTAINER_ID: 'pipeline-graph-container',
STROKE_WIDTH: 2,
errorTexts: {
[DRAW_FAILURE]: __('Could not draw the lines for job relationships'),
[DEFAULT]: __('An unknown error occurred.'),
},
props: { props: {
pipelineData: { pipelineData: {
required: true, required: true,
type: Object, type: Object,
}, },
}, },
data() {
return {
failureType: null,
links: [],
height: 0,
width: 0,
};
},
computed: { computed: {
isPipelineDataEmpty() { isPipelineDataEmpty() {
return isEmpty(this.pipelineData); return isEmpty(this.pipelineData);
}, },
emptyClass() { hasError() {
return !this.isPipelineDataEmpty ? 'gl-py-7' : ''; return this.failureType;
},
failure() {
const text = this.$options.errorTexts[this.failureType] || this.$options.errorTexts[DEFAULT];
return { text, variant: 'danger' };
},
viewBox() {
return [0, 0, this.width, this.height];
},
lineStyle() {
return `stroke-width:${this.$options.STROKE_WIDTH}px;`;
},
},
mounted() {
if (!this.isPipelineDataEmpty) {
this.getGraphDimensions();
this.drawJobLinks();
}
},
methods: {
createJobId(stageName, jobName) {
return createUniqueJobId(stageName, jobName);
},
drawJobLinks() {
const { stages, jobs } = this.pipelineData;
const unwrappedGroups = this.unwrapPipelineData(stages);
try {
const parsedData = parseData(unwrappedGroups);
this.links = generateLinksData(parsedData, jobs, this.$options.CONTAINER_ID);
} catch {
this.reportFailure(DRAW_FAILURE);
}
},
unwrapPipelineData(stages) {
return stages
.map(({ name, groups }) => {
return groups.map(group => {
return { category: name, ...group };
});
})
.flat(2);
},
getGraphDimensions() {
this.width = `${this.$refs[this.$options.CONTAINER_REF].scrollWidth}px`;
this.height = `${this.$refs[this.$options.CONTAINER_REF].scrollHeight}px`;
},
reportFailure(errorType) {
this.failureType = errorType;
},
resetFailure() {
this.failureType = null;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto" :class="emptyClass"> <div>
<gl-alert v-if="hasError" :variant="failure.variant" @dismiss="resetFailure">
{{ failure.text }}
</gl-alert>
<gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false"> <gl-alert v-if="isPipelineDataEmpty" variant="tip" :dismissible="false">
{{ __('No content to show') }} {{ __('No content to show') }}
</gl-alert> </gl-alert>
<template v-else> <div
v-else
:id="$options.CONTAINER_ID"
:ref="$options.CONTAINER_REF"
class="gl-display-flex gl-bg-gray-50 gl-px-4 gl-overflow-auto gl-relative gl-py-7"
>
<svg :viewBox="viewBox" :width="width" :height="height" class="gl-absolute">
<path
v-for="link in links"
:key="link.path"
:d="link.path"
class="gl-stroke-gray-200 gl-fill-transparent"
:style="lineStyle"
/>
</svg>
<div <div
v-for="(stage, index) in pipelineData.stages" v-for="(stage, index) in pipelineData.stages"
:key="`${stage.name}-${index}`" :key="`${stage.name}-${index}`"
...@@ -49,9 +138,14 @@ export default { ...@@ -49,9 +138,14 @@ export default {
<div <div
class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8" class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full gl-px-8"
> >
<job-pill v-for="group in stage.groups" :key="group.name" :job-name="group.name" /> <job-pill
v-for="group in stage.groups"
:key="group.name"
:job-id="createJobId(stage.name, group.name)"
:job-name="group.name"
/>
</div>
</div> </div>
</div> </div>
</template>
</div> </div>
</template> </template>
...@@ -27,6 +27,7 @@ export const RAW_TEXT_WARNING = s__( ...@@ -27,6 +27,7 @@ export const RAW_TEXT_WARNING = s__(
/* Error constants shared across graphs */ /* Error constants shared across graphs */
export const DEFAULT = 'default'; export const DEFAULT = 'default';
export const DELETE_FAILURE = 'delete_pipeline_failure'; export const DELETE_FAILURE = 'delete_pipeline_failure';
export const DRAW_FAILURE = 'draw_failure';
export const LOAD_FAILURE = 'load_failure'; export const LOAD_FAILURE = 'load_failure';
export const PARSE_FAILURE = 'parse_failure'; export const PARSE_FAILURE = 'parse_failure';
export const POST_FAILURE = 'post_failure'; export const POST_FAILURE = 'post_failure';
......
...@@ -18,6 +18,13 @@ export const validateParams = params => { ...@@ -18,6 +18,13 @@ export const validateParams = params => {
export const preparePipelineGraphData = jsonData => { export const preparePipelineGraphData = jsonData => {
const jsonKeys = Object.keys(jsonData); const jsonKeys = Object.keys(jsonData);
const jobNames = jsonKeys.filter(job => jsonData[job]?.stage); const jobNames = jsonKeys.filter(job => jsonData[job]?.stage);
// Creates an object with only the valid jobs
const jobs = jsonKeys.reduce((acc, val) => {
if (jobNames.includes(val)) {
return { ...acc, [val]: { ...jsonData[val] } };
}
return { ...acc };
}, {});
// We merge both the stages from the "stages" key in the yaml and the stage associated // We merge both the stages from the "stages" key in the yaml and the stage associated
// with each job to show the user both the stages they explicitly defined, and those // with each job to show the user both the stages they explicitly defined, and those
...@@ -45,5 +52,7 @@ export const preparePipelineGraphData = jsonData => { ...@@ -45,5 +52,7 @@ export const preparePipelineGraphData = jsonData => {
}; };
}); });
return { stages: pipelineData }; return { stages: pipelineData, jobs };
}; };
export const createUniqueJobId = (stageName, jobName) => `${stageName}-${jobName}`;
...@@ -7325,6 +7325,9 @@ msgstr "" ...@@ -7325,6 +7325,9 @@ msgstr ""
msgid "Could not delete wiki page" msgid "Could not delete wiki page"
msgstr "" msgstr ""
msgid "Could not draw the lines for job relationships"
msgstr ""
msgid "Could not find design." msgid "Could not find design."
msgstr "" msgstr ""
......
...@@ -3,7 +3,7 @@ import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; ...@@ -3,7 +3,7 @@ import DagGraph from '~/pipelines/components/dag/dag_graph.vue';
import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants'; import { IS_HIGHLIGHTED, LINK_SELECTOR, NODE_SELECTOR } from '~/pipelines/components/dag/constants';
import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions'; import { highlightIn, highlightOut } from '~/pipelines/components/dag/interactions';
import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { removeOrphanNodes } from '~/pipelines/components/dag/parsing_utils'; import { removeOrphanNodes } from '~/pipelines/components/parsing_utils';
import { parsedData } from './mock_data'; import { parsedData } from './mock_data';
describe('The DAG graph', () => { describe('The DAG graph', () => {
......
import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { parseData } from '~/pipelines/components/dag/parsing_utils'; import { parseData } from '~/pipelines/components/parsing_utils';
import { mockParsedGraphQLNodes } from './mock_data'; import { mockParsedGraphQLNodes } from './mock_data';
describe('DAG visualization drawing utilities', () => { describe('DAG visualization drawing utilities', () => {
......
...@@ -5,7 +5,7 @@ import { ...@@ -5,7 +5,7 @@ import {
parseData, parseData,
removeOrphanNodes, removeOrphanNodes,
getMaxNodes, getMaxNodes,
} from '~/pipelines/components/dag/parsing_utils'; } from '~/pipelines/components/parsing_utils';
import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils';
import { mockParsedGraphQLNodes } from './mock_data'; import { mockParsedGraphQLNodes } from './mock_data';
......
import { preparePipelineGraphData } from '~/pipelines/utils'; import { preparePipelineGraphData } from '~/pipelines/utils';
describe('preparePipelineGraphData', () => { describe('preparePipelineGraphData', () => {
const emptyResponse = { stages: [] }; const emptyResponse = { stages: [], jobs: {} };
const jobName1 = 'build_1'; const jobName1 = 'build_1';
const jobName2 = 'build_2'; const jobName2 = 'build_2';
const jobName3 = 'test_1'; const jobName3 = 'test_1';
...@@ -11,7 +11,7 @@ describe('preparePipelineGraphData', () => { ...@@ -11,7 +11,7 @@ describe('preparePipelineGraphData', () => {
const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } }; const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } };
const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } }; const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } };
describe('returns an object with an empty array of stages if', () => { describe('returns an empty array of stages and empty job objects if', () => {
it('no data is passed', () => { it('no data is passed', () => {
expect(preparePipelineGraphData({})).toEqual(emptyResponse); expect(preparePipelineGraphData({})).toEqual(emptyResponse);
}); });
...@@ -23,7 +23,7 @@ describe('preparePipelineGraphData', () => { ...@@ -23,7 +23,7 @@ describe('preparePipelineGraphData', () => {
}); });
}); });
describe('returns the correct array of stages', () => { describe('returns the correct array of stages and object of jobs', () => {
it('when multiple jobs are in the same stage', () => { it('when multiple jobs are in the same stage', () => {
const expectedData = { const expectedData = {
stages: [ stages: [
...@@ -41,6 +41,7 @@ describe('preparePipelineGraphData', () => { ...@@ -41,6 +41,7 @@ describe('preparePipelineGraphData', () => {
], ],
}, },
], ],
jobs: { ...job1, ...job2 },
}; };
expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData); expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData);
...@@ -61,6 +62,7 @@ describe('preparePipelineGraphData', () => { ...@@ -61,6 +62,7 @@ describe('preparePipelineGraphData', () => {
groups: [], groups: [],
}, },
], ],
jobs: {},
}; };
expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual( expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual(
...@@ -110,6 +112,12 @@ describe('preparePipelineGraphData', () => { ...@@ -110,6 +112,12 @@ describe('preparePipelineGraphData', () => {
], ],
}, },
], ],
jobs: {
...job1,
...job2,
...job3,
...job4,
},
}; };
expect( expect(
...@@ -136,6 +144,9 @@ describe('preparePipelineGraphData', () => { ...@@ -136,6 +144,9 @@ describe('preparePipelineGraphData', () => {
], ],
}, },
], ],
jobs: {
...job1,
},
}; };
expect( expect(
......
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