Add job graph support to web UI

This adds the ability to display the frozen job graph for a project.

It adds a toolbar to the Project page that allows a user to enter
a pipeline and branch.  Hit the button and it will use the API
to freeze the job graph and then display it with graphviz (there
is a webassembly build of the graphviz libray).

Change-Id: Ieb5899a63a4c85eb5d445fa69dd1e85ddc11575d
This commit is contained in:
James E. Blair
2022-07-26 19:11:02 -07:00
parent 042d01ebbb
commit 97376adc21
11 changed files with 571 additions and 7 deletions
+2 -1
View File
@@ -13,6 +13,7 @@
"@softwarefactory-project/re-ansi": "^0.5.0",
"axios": "^0.26.0",
"broadcast-channel": "^4.5.0",
"d3-graphviz": "2.6.1",
"js-yaml": "^3.13.0",
"lodash": "^4.17.10",
"moment": "^2.22.2",
@@ -57,7 +58,7 @@
"start:openstack": "REACT_APP_ZUUL_API='https://zuul.openstack.org/api/' react-scripts start",
"start:multi": "REACT_APP_ZUUL_API='https://softwarefactory-project.io/zuul/api/' react-scripts start",
"start": "react-scripts start",
"build": "react-scripts build",
"build": "react-scripts --max_old_space_size=4096 build",
"test": "react-scripts test --env=jsdom --watchAll=false",
"eject": "react-scripts eject",
"lint": "eslint --ext .js --ext .jsx src",
+83
View File
@@ -0,0 +1,83 @@
// Copyright 2018 Red Hat, Inc
// Copyright 2022 Acme Gating, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import * as API from '../api'
export const JOB_GRAPH_FETCH_REQUEST = 'JOB_GRAPH_FETCH_REQUEST'
export const JOB_GRAPH_FETCH_SUCCESS = 'JOB_GRAPH_FETCH_SUCCESS'
export const JOB_GRAPH_FETCH_FAIL = 'JOB_GRAPH_FETCH_FAIL'
export const requestJobGraph = () => ({
type: JOB_GRAPH_FETCH_REQUEST
})
export function makeJobGraphKey(project, pipeline, branch) {
return JSON.stringify({
project: project, pipeline: pipeline, branch: branch
})
}
export const receiveJobGraph = (tenant, jobGraphKey, jobGraph) => {
return {
type: JOB_GRAPH_FETCH_SUCCESS,
tenant: tenant,
jobGraphKey: jobGraphKey,
jobGraph: jobGraph,
receivedAt: Date.now(),
}
}
const failedJobGraph = error => ({
type: JOB_GRAPH_FETCH_FAIL,
error
})
const fetchJobGraph = (tenant, project, pipeline, branch) => dispatch => {
dispatch(requestJobGraph())
const jobGraphKey = makeJobGraphKey(project, pipeline, branch)
return API.fetchJobGraph(tenant.apiPrefix,
project,
pipeline,
branch)
.then(response => dispatch(receiveJobGraph(
tenant.name, jobGraphKey, response.data)))
.catch(error => dispatch(failedJobGraph(error)))
}
const shouldFetchJobGraph = (tenant, project, pipeline, branch, state) => {
const jobGraphKey = makeJobGraphKey(project, pipeline, branch)
const tenantJobGraphs = state.jobgraph.jobGraphs[tenant.name]
if (tenantJobGraphs) {
const jobGraph = tenantJobGraphs[jobGraphKey]
if (!jobGraph) {
return true
}
if (jobGraph.isFetching) {
return false
}
return false
}
return true
}
export const fetchJobGraphIfNeeded = (tenant, project, pipeline, branch,
force) => (
dispatch, getState) => {
if (force || shouldFetchJobGraph(tenant, project, pipeline, branch,
getState())) {
return dispatch(fetchJobGraph(tenant, project, pipeline, branch))
}
return Promise.resolve()
}
+8
View File
@@ -159,6 +159,13 @@ function fetchProjects(apiPrefix) {
function fetchJob(apiPrefix, jobName) {
return Axios.get(apiUrl + apiPrefix + 'job/' + jobName)
}
function fetchJobGraph(apiPrefix, projectName, pipelineName, branchName) {
return Axios.get(apiUrl + apiPrefix +
'pipeline/' + pipelineName +
'/project/' + projectName +
'/branch/' + branchName +
'/freeze-jobs')
}
function fetchJobs(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'jobs')
}
@@ -308,6 +315,7 @@ export {
fetchProject,
fetchProjects,
fetchJob,
fetchJobGraph,
fetchJobs,
fetchLabels,
fetchNodes,
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2022 Acme Gating, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { useHistory, useLocation } from 'react-router-dom'
import JobGraphToolbar from './JobGraphToolbar'
import JobGraphDisplay from './JobGraphDisplay'
function JobGraph(props) {
const [currentPipeline, setCurrentPipeline] = useState()
const [currentBranch, setCurrentBranch] = useState()
const history = useHistory()
const location = useLocation()
if (!currentBranch) {
const urlParams = new URLSearchParams(location.search)
const branch = urlParams.get('branch')
const pipeline = urlParams.get('pipeline')
if (pipeline && branch) {
setCurrentPipeline(pipeline)
setCurrentBranch(branch)
}
}
function onChange(pipeline, branch) {
setCurrentPipeline(pipeline)
setCurrentBranch(branch)
const searchParams = new URLSearchParams('')
searchParams.append('branch', branch)
searchParams.append('pipeline', pipeline)
history.push({
pathname: location.pathname,
search: searchParams.toString(),
})
}
return (
<>
<JobGraphToolbar
project={props.project}
onChange={onChange}
defaultBranch={currentBranch}
defaultPipeline={currentPipeline}
/>
{currentPipeline && currentBranch &&
<JobGraphDisplay
project={props.project}
pipeline={currentPipeline}
branch={currentBranch}
/>}
</>
)
}
JobGraph.propTypes = {
project: PropTypes.object.isRequired,
tenant: PropTypes.object,
dispatch: PropTypes.func,
}
export default connect((state) => ({
tenant: state.tenant,
}))(JobGraph)
@@ -0,0 +1,120 @@
// Copyright 2022 Acme Gating, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React, { useState, useEffect} from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import * as d3 from 'd3'
import { makeJobGraphKey, fetchJobGraphIfNeeded } from '../../actions/jobgraph'
import { graphviz } from 'd3-graphviz'
function makeDot(jobGraph) {
let ret = 'digraph job_graph {'
ret += ' rankdir=LR;\n'
ret += ' node [shape=box];\n'
jobGraph.forEach((job) => {
if (job.dependencies.length) {
job.dependencies.forEach((dep) => {
let soft = ' [dir=back]'
if (dep.soft) {
soft = ' [style=dashed dir=back]'
}
ret += ' "' + dep.name + '" -> "' + job.name + '"' + soft + ';\n'
})
} else {
ret += ' "' + job.name + '";\n'
}
})
ret += '}\n'
return ret
}
function GraphViz(props) {
useEffect(() => {
const gv = graphviz('#graphviz')
.options({
fit: false,
zoom: true,
tweenPaths: false,
scale: 0.75,
}).renderDot(props.dot)
// Fix up the initial values of the internal transform data;
// without this the first time we pan the graph jumps.
const element = d3.select('.zuul-job-graph > svg')
const transform = element[0][0].firstElementChild.attributes.transform.value
const match = transform.match(/translate\(\d+,(\d+)\).*/)
if (match && match.length > 0) {
const val = parseInt(match[1])
gv._translation.y = val
gv._originalTransform.y = val
}
}, [props.dot])
return (
<div className="zuul-job-graph" id="graphviz"/>
)
}
GraphViz.propTypes = {
dot: PropTypes.string.isRequired,
}
function JobGraphDisplay(props) {
const [dot, setDot] = useState()
const {fetchJobGraphIfNeeded, tenant, project, pipeline, branch} = props
useEffect(() => {
fetchJobGraphIfNeeded(tenant, project.name, pipeline, branch)
}, [fetchJobGraphIfNeeded, tenant, project, pipeline, branch])
const tenantJobGraph = props.jobgraph.jobGraphs[props.tenant.name]
const jobGraphKey = makeJobGraphKey(props.project.name,
props.pipeline,
props.branch)
const jobGraph = tenantJobGraph ? tenantJobGraph[jobGraphKey] : undefined
useEffect(() => {
if (jobGraph) {
setDot(makeDot(jobGraph))
}
}, [jobGraph])
return (
<>
{dot && <GraphViz dot={dot}/>}
</>
)
}
JobGraphDisplay.propTypes = {
fetchJobGraphIfNeeded: PropTypes.func,
tenant: PropTypes.object,
project: PropTypes.object.isRequired,
pipeline: PropTypes.string.isRequired,
branch: PropTypes.string.isRequired,
jobgraph: PropTypes.object,
dispatch: PropTypes.func,
state: PropTypes.object,
}
function mapStateToProps(state) {
return {
tenant: state.tenant,
jobgraph: state.jobgraph,
state: state,
}
}
const mapDispatchToProps = { fetchJobGraphIfNeeded }
export default connect(mapStateToProps, mapDispatchToProps)(JobGraphDisplay)
@@ -0,0 +1,145 @@
// Copyright 2020 BMW Group
// Copyright 2022 Acme Gating, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import React, { useState } from 'react'
import PropTypes from 'prop-types'
import {
Button,
TextInput,
Dropdown,
DropdownItem,
DropdownPosition,
DropdownToggle,
Toolbar,
ToolbarContent,
ToolbarGroup,
ToolbarItem,
ToolbarToggleGroup,
} from '@patternfly/react-core'
function JobGraphToolbar(props) {
const pipelineSet = new Set()
props.project.configs.forEach((config) => {
config.pipelines.forEach((pipeline) => {
pipelineSet.add(pipeline.name)
})
})
const pipelines = Array.from(pipelineSet)
const [isPipelineOpen, setIsPipelineOpen] = useState(false)
const [currentPipeline, setCurrentPipeline] = useState(props.defaultPipeline || pipelines[0])
const [currentBranch, setCurrentBranch] = useState(props.defaultBranch || '')
function handlePipelineSelect(event) {
setCurrentPipeline(event.target.innerText)
setIsPipelineOpen(false)
}
function handlePipelineToggle(isOpen) {
setIsPipelineOpen(isOpen)
}
function handleBranchChange(newValue) {
setCurrentBranch(newValue)
}
function handleInputSend(event) {
// In case the event comes from a key press, only accept "Enter"
if (event.key && event.key !== 'Enter') {
return
}
// Ignore empty values
if (!currentBranch) {
return
}
// Clear the input field
setCurrentBranch('')
// Notify the parent component about the filter change
props.onChange(currentPipeline, currentBranch)
}
function renderJobGraphToolbar () {
return <>
<Toolbar collapseListedFiltersBreakpoint="md">
<ToolbarContent>
<ToolbarToggleGroup breakpoint="md">
<ToolbarGroup variant="filter-group">
<ToolbarItem key="pipeline">
<Dropdown
onSelect={handlePipelineSelect}
position={DropdownPosition.left}
toggle={
<DropdownToggle
onToggle={handlePipelineToggle}
style={{ width: '100%' }}
>
Pipeline: {currentPipeline}
</DropdownToggle>
}
isOpen={isPipelineOpen}
dropdownItems={pipelines.map((pipeline) => (
<DropdownItem key={pipeline}>{pipeline}</DropdownItem>
))}
style={{ width: '100%' }}
menuAppendTo={document.body}
/>
</ToolbarItem>
<ToolbarItem key="branch">
<TextInput
name="branch"
id="branch-input"
type="search"
placeholder="Branch"
defaultValue={props.defaultBranch}
onChange={handleBranchChange}
onKeyDown={(event) => handleInputSend(event)}
/>
</ToolbarItem>
<ToolbarItem key="button">
<Button
onClick={(event) => handleInputSend(event)}
>
View Job Graph
</Button>
</ToolbarItem>
</ToolbarGroup>
</ToolbarToggleGroup>
</ToolbarContent>
</Toolbar>
</>
}
return (
<div>
{renderJobGraphToolbar()}
</div>
)
}
JobGraphToolbar.propTypes = {
project: PropTypes.object.isRequired,
onChange: PropTypes.func.isRequired,
defaultBranch: PropTypes.string,
defaultPipeline: PropTypes.string,
}
export default JobGraphToolbar
+6
View File
@@ -443,3 +443,9 @@ details.foldable[open] summary::before {
);
}
}
/* The box size calculation compared to the text size seems off, but
this looks better */
.zuul-job-graph text {
font-size: 12px;
}
+6 -1
View File
@@ -18,6 +18,7 @@ import PropTypes from 'prop-types'
import { PageSection, PageSectionVariants } from '@patternfly/react-core'
import Project from '../containers/project/Project'
import JobGraph from '../containers/jobgraph/JobGraph'
import { fetchProjectIfNeeded } from '../actions/project'
import { Fetchable } from '../containers/Fetching'
@@ -61,7 +62,11 @@ class ProjectPage extends React.Component {
/>
</PageSection>
{tenantProjects && tenantProjects[projectName] &&
<Project project={tenantProjects[projectName]} />}
<>
<Project project={tenantProjects[projectName]} />
<JobGraph project={tenantProjects[projectName]} />
</>
}
</PageSection>
)
}
+2
View File
@@ -23,6 +23,7 @@ import notifications from './notifications'
import build from './build'
import info from './info'
import job from './job'
import jobgraph from './jobgraph'
import jobs from './jobs'
import labels from './labels'
import logfile from './logfile'
@@ -47,6 +48,7 @@ const reducers = {
notifications,
info,
job,
jobgraph,
jobs,
labels,
logfile,
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2018 Red Hat, Inc
// Copyright 2022 Acme Gating, LLC
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License. You may obtain
// a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.
import {
JOB_GRAPH_FETCH_FAIL,
JOB_GRAPH_FETCH_REQUEST,
JOB_GRAPH_FETCH_SUCCESS
} from '../actions/jobgraph'
export default (state = {
isFetching: false,
jobGraphs: {},
}, action) => {
let stateJobGraphs
switch (action.type) {
case JOB_GRAPH_FETCH_REQUEST:
return {
isFetching: true,
jobGraphs: state.jobGraphs,
}
case JOB_GRAPH_FETCH_SUCCESS:
stateJobGraphs = !state.jobGraphs[action.tenant] ?
{ ...state.jobGraphs, [action.tenant]: {} } :
{ ...state.jobGraphs }
return {
isFetching: false,
jobGraphs: {
...stateJobGraphs,
[action.tenant]: {
...stateJobGraphs[action.tenant],
[action.jobGraphKey]: action.jobGraph
}
}
}
case JOB_GRAPH_FETCH_FAIL:
return {
isFetching: false,
jobGraphs: state.jobGraphs,
}
default:
return state
}
}
+66 -5
View File
@@ -4711,24 +4711,52 @@ d3-color@1:
resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-1.4.1.tgz#c52002bf8846ada4424d55d97982fef26eb3bc8a"
integrity sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==
d3-ease@^1.0.0:
d3-dispatch@1, d3-dispatch@^1.0.3:
version "1.0.6"
resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-1.0.6.tgz#00d37bcee4dd8cd97729dd893a0ac29caaba5d58"
integrity sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==
d3-drag@1:
version "1.2.5"
resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-1.2.5.tgz#2537f451acd39d31406677b7dc77c82f7d988f70"
integrity sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==
dependencies:
d3-dispatch "1"
d3-selection "1"
d3-ease@1, d3-ease@^1.0.0:
version "1.0.7"
resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-1.0.7.tgz#9a834890ef8b8ae8c558b2fe55bd57f5993b85e2"
integrity sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==
d3-format@1:
d3-format@1, d3-format@^1.2.0:
version "1.4.5"
resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4"
integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==
d3-interpolate@1, d3-interpolate@^1.1.1:
d3-graphviz@2.6.1:
version "2.6.1"
resolved "https://registry.yarnpkg.com/d3-graphviz/-/d3-graphviz-2.6.1.tgz#61b93fe330e6339198fd2090f8080d7d4282c514"
integrity sha512-878AFSagQyr5tTOrM7YiVYeUC2/NoFcOB3/oew+LAML0xekyJSw9j3WOCUMBsc95KYe9XBYZ+SKKuObVya1tJQ==
dependencies:
d3-dispatch "^1.0.3"
d3-format "^1.2.0"
d3-interpolate "^1.1.5"
d3-path "^1.0.5"
d3-selection "^1.1.0"
d3-timer "^1.0.6"
d3-transition "^1.1.1"
d3-zoom "^1.5.0"
viz.js "^1.8.2"
d3-interpolate@1, d3-interpolate@^1.1.1, d3-interpolate@^1.1.5:
version "1.4.0"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-1.4.0.tgz#526e79e2d80daa383f9e0c1c1c7dcc0f0583e987"
integrity sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==
dependencies:
d3-color "1"
d3-path@1:
d3-path@1, d3-path@^1.0.5:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==
@@ -4746,6 +4774,11 @@ d3-scale@^1.0.0:
d3-time "1"
d3-time-format "2"
d3-selection@1, d3-selection@^1.1.0:
version "1.4.2"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-1.4.2.tgz#dcaa49522c0dbf32d6c1858afc26b6094555bc5c"
integrity sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==
d3-shape@^1.0.0, d3-shape@^1.2.0:
version "1.3.7"
resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-1.3.7.tgz#df63801be07bc986bc54f63789b4fe502992b5d7"
@@ -4765,11 +4798,34 @@ d3-time@1:
resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-1.1.0.tgz#b1e19d307dae9c900b7e5b25ffc5dcc249a8a0f1"
integrity sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==
d3-timer@^1.0.0:
d3-timer@1, d3-timer@^1.0.0, d3-timer@^1.0.6:
version "1.0.10"
resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-1.0.10.tgz#dfe76b8a91748831b13b6d9c793ffbd508dd9de5"
integrity sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==
d3-transition@1, d3-transition@^1.1.1:
version "1.3.2"
resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-1.3.2.tgz#a98ef2151be8d8600543434c1ca80140ae23b398"
integrity sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==
dependencies:
d3-color "1"
d3-dispatch "1"
d3-ease "1"
d3-interpolate "1"
d3-selection "^1.1.0"
d3-timer "1"
d3-zoom@^1.5.0:
version "1.8.3"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-1.8.3.tgz#b6a3dbe738c7763121cd05b8a7795ffe17f4fc0a"
integrity sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==
dependencies:
d3-dispatch "1"
d3-drag "1"
d3-interpolate "1"
d3-selection "1"
d3-transition "1"
d3@~3.5.0, d3@~3.5.17:
version "3.5.17"
resolved "https://registry.yarnpkg.com/d3/-/d3-3.5.17.tgz#bc46748004378b21a360c9fc7cf5231790762fb8"
@@ -15208,6 +15264,11 @@ victory-zoom-container@^36.2.1, victory-zoom-container@^36.3.0:
prop-types "^15.5.8"
victory-core "^36.3.0"
viz.js@^1.8.2:
version "1.8.2"
resolved "https://registry.yarnpkg.com/viz.js/-/viz.js-1.8.2.tgz#d9cc04cd99f98ec986bf9054db76a6cbcdc5d97a"
integrity sha512-W+1+N/hdzLpQZEcvz79n2IgUE9pfx6JLdHh3Kh8RGvLL8P1LdJVQmi2OsDcLdY4QVID4OUy+FPelyerX0nJxIQ==
vm-browserify@^1.0.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0"