mirror of
https://github.com/ansible/awx.git
synced 2026-05-13 20:37:39 -02:30
Render workflow results. Extensive refactors of workflow components in general.
This commit is contained in:
@@ -11,6 +11,7 @@ module.exports = {
|
|||||||
'\\.(css|scss|less)$': '<rootDir>/__mocks__/styleMock.js',
|
'\\.(css|scss|less)$': '<rootDir>/__mocks__/styleMock.js',
|
||||||
'^@api(.*)$': '<rootDir>/src/api$1',
|
'^@api(.*)$': '<rootDir>/src/api$1',
|
||||||
'^@components(.*)$': '<rootDir>/src/components$1',
|
'^@components(.*)$': '<rootDir>/src/components$1',
|
||||||
|
'@constants$': '<rootDir>/src/constants.js',
|
||||||
'^@contexts(.*)$': '<rootDir>/src/contexts$1',
|
'^@contexts(.*)$': '<rootDir>/src/contexts$1',
|
||||||
'^@screens(.*)$': '<rootDir>/src/screens$1',
|
'^@screens(.*)$': '<rootDir>/src/screens$1',
|
||||||
'^@util(.*)$': '<rootDir>/src/util$1',
|
'^@util(.*)$': '<rootDir>/src/util$1',
|
||||||
|
|||||||
122
awx/ui_next/package-lock.json
generated
122
awx/ui_next/package-lock.json
generated
@@ -7115,9 +7115,9 @@
|
|||||||
"integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ=="
|
"integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ=="
|
||||||
},
|
},
|
||||||
"d3-brush": {
|
"d3-brush": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.5.tgz",
|
||||||
"integrity": "sha512-v8bbYyCFKjyCzFk/tdWqXwDykY8YWqhXYjcYxfILIit085VZOpj4XJKOMccTsvWxgzSLMJQg5SiqHjslsipEDg==",
|
"integrity": "sha512-rEaJ5gHlgLxXugWjIkolTA0OyMvw8UWU1imYXy1v642XyyswmI1ybKOv05Ft+ewq+TFmdliD3VuK0pRp1VT/5A==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-dispatch": "1",
|
"d3-dispatch": "1",
|
||||||
"d3-drag": "1",
|
"d3-drag": "1",
|
||||||
@@ -7141,9 +7141,9 @@
|
|||||||
"integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A=="
|
"integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A=="
|
||||||
},
|
},
|
||||||
"d3-color": {
|
"d3-color": {
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.0.tgz",
|
||||||
"integrity": "sha512-NHODMBlj59xPAwl2BDiO2Mog6V+PrGRtBfWKqKRrs9MCqlSkIEb0Z/SfY7jW29ReHTDC/j+vwXhnZcXI3+3fbg=="
|
"integrity": "sha512-TzNPeJy2+iEepfiL92LAAB7fvnp/dV2YwANPVHdDWmYMm23qIJBYww3qT8I8C1wXrmrg4UWs7BKc2tKIgyjzHg=="
|
||||||
},
|
},
|
||||||
"d3-contour": {
|
"d3-contour": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
@@ -7154,23 +7154,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-dispatch": {
|
"d3-dispatch": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz",
|
||||||
"integrity": "sha512-vwKx+lAqB1UuCeklr6Jh1bvC4SZgbSqbkGBLClItFBIYH4vqDJCA7qfoy14lXmJdnBOdxndAMxjCbImJYW7e6g=="
|
"integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA=="
|
||||||
},
|
},
|
||||||
"d3-drag": {
|
"d3-drag": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz",
|
||||||
"integrity": "sha512-ICPurDETFAelF1CTHdIyiUM4PsyZLaM+7oIBhmyP+cuVjze5vDZ8V//LdOFjg0jGnFIZD/Sfmk0r95PSiu78rw==",
|
"integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-dispatch": "1",
|
"d3-dispatch": "1",
|
||||||
"d3-selection": "1"
|
"d3-selection": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-dsv": {
|
"d3-dsv": {
|
||||||
"version": "1.1.1",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz",
|
||||||
"integrity": "sha512-1EH1oRGSkeDUlDRbhsFytAXU6cAmXFzc52YUe6MRlPClmWb85MP1J5x+YJRzya4ynZWnbELdSAvATFW/MbxaXw==",
|
"integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"commander": "2",
|
"commander": "2",
|
||||||
"iconv-lite": "0.4",
|
"iconv-lite": "0.4",
|
||||||
@@ -7178,9 +7178,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-ease": {
|
"d3-ease": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.6.tgz",
|
||||||
"integrity": "sha512-Ct1O//ly5y5lFM9YTdu+ygq7LleSgSE4oj7vUt9tPLHUi8VCV7QoizGpdWRWAwCO9LdYzIrQDg97+hGVdsSGPQ=="
|
"integrity": "sha512-SZ/lVU7LRXafqp7XtIcBdxnWl8yyLpgOmzAk0mWBI9gXNzLDx5ybZgnRbH9dN/yY5tzVBqCQ9avltSnqVwessQ=="
|
||||||
},
|
},
|
||||||
"d3-fetch": {
|
"d3-fetch": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
@@ -7202,45 +7202,45 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-format": {
|
"d3-format": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.2.tgz",
|
||||||
"integrity": "sha512-TUswGe6hfguUX1CtKxyG2nymO+1lyThbkS1ifLX0Sr+dOQtAD5gkrffpHnx+yHNKUZ0Bmg5T4AjUQwugPDrm0g=="
|
"integrity": "sha512-gco1Ih54PgMsyIXgttLxEhNy/mXxq8+rLnCb5shQk+P5TsiySrwWU5gpB4zen626J4LIwBxHvDChyA8qDm57ww=="
|
||||||
},
|
},
|
||||||
"d3-geo": {
|
"d3-geo": {
|
||||||
"version": "1.11.6",
|
"version": "1.11.9",
|
||||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.11.6.tgz",
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.11.9.tgz",
|
||||||
"integrity": "sha512-z0J8InXR9e9wcgNtmVnPTj0TU8nhYT6lD/ak9may2PdKqXIeHUr8UbFLoCtrPYNsjv6YaLvSDQVl578k6nm7GA==",
|
"integrity": "sha512-9edcH6J3s/Aa3KJITWqFJbyB/8q3mMlA9Fi7z6yy+FAYMnRaxmC7jBhUnsINxVWD14GmqX3DK8uk7nV6/Ekt4A==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-array": "1"
|
"d3-array": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-hierarchy": {
|
"d3-hierarchy": {
|
||||||
"version": "1.1.8",
|
"version": "1.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz",
|
||||||
"integrity": "sha512-L+GHMSZNwTpiq4rt9GEsNcpLa4M96lXMR8M/nMG9p5hBE0jy6C+3hWtyZMenPQdwla249iJy7Nx0uKt3n+u9+w=="
|
"integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ=="
|
||||||
},
|
},
|
||||||
"d3-interpolate": {
|
"d3-interpolate": {
|
||||||
"version": "1.3.2",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz",
|
||||||
"integrity": "sha512-NlNKGopqaz9qM1PXh9gBF1KSCVh+jSFErrSlD/4hybwoNX/gt1d8CDbDW+3i+5UOHhjC6s6nMvRxcuoMVNgL2w==",
|
"integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-color": "1"
|
"d3-color": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-path": {
|
"d3-path": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz",
|
||||||
"integrity": "sha512-J6EfUNwcMQ+aM5YPOB8ZbgAZu6wc82f/0WFxrxwV6Ll8wBwLaHLKCqQ5Imub02JriCVVdPjgI+6P3a4EWJCxAg=="
|
"integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="
|
||||||
},
|
},
|
||||||
"d3-polygon": {
|
"d3-polygon": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz",
|
||||||
"integrity": "sha512-RHhh1ZUJZfhgoqzWWuRhzQJvO7LavchhitSTHGu9oj6uuLFzYZVeBzaWTQ2qSO6bz2w55RMoOCf0MsLCDB6e0w=="
|
"integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ=="
|
||||||
},
|
},
|
||||||
"d3-quadtree": {
|
"d3-quadtree": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz",
|
||||||
"integrity": "sha512-NUgeo9G+ENQCQ1LsRr2qJg3MQ4DJvxcDNCiohdJGHt5gRhBW6orIB5m5FJ9kK3HNL8g9F4ERVoBzcEwQBfXWVA=="
|
"integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA=="
|
||||||
},
|
},
|
||||||
"d3-random": {
|
"d3-random": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
@@ -7270,40 +7270,40 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-selection": {
|
"d3-selection": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.1.tgz",
|
||||||
"integrity": "sha512-EYVwBxQGEjLCKF2pJ4+yrErskDnz5v403qvAid96cNdCMr8rmCYfY5RGzWz24mdIbxmDf6/4EAH+K9xperD5jg=="
|
"integrity": "sha512-BTIbRjv/m5rcVTfBs4AMBLKs4x8XaaLkwm28KWu9S2vKNqXkXt2AH2Qf0sdPZHjFxcWg/YL53zcqAz+3g4/7PA=="
|
||||||
},
|
},
|
||||||
"d3-shape": {
|
"d3-shape": {
|
||||||
"version": "1.3.5",
|
"version": "1.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz",
|
||||||
"integrity": "sha512-VKazVR3phgD+MUCldapHD7P9kcrvPcexeX/PkMJmkUov4JM8IxsSg1DvbYoYich9AtdTsa5nNk2++ImPiDiSxg==",
|
"integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-path": "1"
|
"d3-path": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-time": {
|
"d3-time": {
|
||||||
"version": "1.0.11",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz",
|
||||||
"integrity": "sha512-Z3wpvhPLW4vEScGeIMUckDW7+3hWKOQfAWg/U7PlWBnQmeKQ00gCUsTtWSYulrKNA7ta8hJ+xXc6MHrMuITwEw=="
|
"integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA=="
|
||||||
},
|
},
|
||||||
"d3-time-format": {
|
"d3-time-format": {
|
||||||
"version": "2.1.3",
|
"version": "2.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.2.2.tgz",
|
||||||
"integrity": "sha512-6k0a2rZryzGm5Ihx+aFMuO1GgelgIz+7HhB4PH4OEndD5q2zGn1mDfRdNrulspOfR6JXkb2sThhDK41CSK85QA==",
|
"integrity": "sha512-pweL2Ri2wqMY+wlW/wpkl8T3CUzKAha8S9nmiQlMABab8r5MJN0PD1V4YyRNVaKQfeh4Z0+VO70TLw6ESVOYzw==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-time": "1"
|
"d3-time": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"d3-timer": {
|
"d3-timer": {
|
||||||
"version": "1.0.9",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz",
|
||||||
"integrity": "sha512-rT34J5HnQUHhcLvhSB9GjCkN0Ddd5Y8nCwDBG2u6wQEeYxT/Lf51fTFFkldeib/sE/J0clIe0pnCfs6g/lRbyg=="
|
"integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw=="
|
||||||
},
|
},
|
||||||
"d3-transition": {
|
"d3-transition": {
|
||||||
"version": "1.2.0",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz",
|
||||||
"integrity": "sha512-VJ7cmX/FPIPJYuaL2r1o1EMHLttvoIuZhhuAlRoOxDzogV8iQS6jYulDm3xEU3TqL80IZIhI551/ebmCMrkvhw==",
|
"integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"d3-color": "1",
|
"d3-color": "1",
|
||||||
"d3-dispatch": "1",
|
"d3-dispatch": "1",
|
||||||
@@ -10090,11 +10090,11 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"graphlib": {
|
"graphlib": {
|
||||||
"version": "2.1.7",
|
"version": "2.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||||
"integrity": "sha512-TyI9jIy2J4j0qgPmOOrHTCtpPqJGN/aurBwc6ZT+bRii+di1I+Wv3obRhVrmBEXet+qkMaEX67dXrwsd3QQM6w==",
|
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
|
||||||
"requires": {
|
"requires": {
|
||||||
"lodash": "^4.17.5"
|
"lodash": "^4.17.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"growly": {
|
"growly": {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ class WorkflowJobs extends RelaunchMixin(Base) {
|
|||||||
super(http);
|
super(http);
|
||||||
this.baseUrl = '/api/v2/workflow_jobs/';
|
this.baseUrl = '/api/v2/workflow_jobs/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readNodes(id, params) {
|
||||||
|
return this.http.get(`${this.baseUrl}${id}/workflow_nodes/`, { params });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WorkflowJobs;
|
export default WorkflowJobs;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Tooltip } from '@patternfly/react-core';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import { formatDateString } from '@util/dates';
|
import { formatDateString } from '@util/dates';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||||
|
|
||||||
/* eslint-disable react/jsx-pascal-case */
|
/* eslint-disable react/jsx-pascal-case */
|
||||||
const Link = styled(props => <_Link {...props} />)`
|
const Link = styled(props => <_Link {...props} />)`
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const TooltipContents = styled.div`
|
||||||
|
display: flex;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TooltipArrows = styled.div`
|
||||||
|
width: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TooltipArrowOuter = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 10px);
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-right: 10px solid #c4c4c4;
|
||||||
|
border-top: 10px solid transparent;
|
||||||
|
border-bottom: 10px solid transparent;
|
||||||
|
margin: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TooltipArrowInner = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: calc(50% - 10px);
|
||||||
|
left: 2px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-right: 10px solid white;
|
||||||
|
border-top: 10px solid transparent;
|
||||||
|
border-bottom: 10px solid transparent;
|
||||||
|
margin: auto;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TooltipActions = styled.div`
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #c4c4c4;
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function WorkflowActionTooltip({ actions, pointX, pointY }) {
|
||||||
|
const tipHeight = 25 * actions.length + 5 * actions.length - 1 + 10;
|
||||||
|
return (
|
||||||
|
<foreignObject
|
||||||
|
x={pointX}
|
||||||
|
y={Number(pointY) - tipHeight / 2}
|
||||||
|
width="52"
|
||||||
|
height={tipHeight}
|
||||||
|
>
|
||||||
|
<TooltipContents>
|
||||||
|
<TooltipArrows>
|
||||||
|
<TooltipArrowOuter />
|
||||||
|
<TooltipArrowInner />
|
||||||
|
</TooltipArrows>
|
||||||
|
<TooltipActions>{actions}</TooltipActions>
|
||||||
|
</TooltipContents>
|
||||||
|
</foreignObject>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowActionTooltip;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const TooltipItem = styled.div`
|
||||||
|
height: 25px;
|
||||||
|
width: 25px;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: white;
|
||||||
|
background-color: #c4c4c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(:last-of-type) {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function WorkflowActionTooltip({
|
||||||
|
children,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
onClick,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TooltipItem
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TooltipItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowActionTooltip;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { Fragment } from 'react';
|
import React from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
const Outer = styled.div`
|
const Outer = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Inner = styled.div`
|
const Inner = styled.div`
|
||||||
@@ -19,11 +20,9 @@ const Inner = styled.div`
|
|||||||
|
|
||||||
function WorkflowHelp({ children }) {
|
function WorkflowHelp({ children }) {
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Outer>
|
||||||
<Outer>
|
<Inner>{children}</Inner>
|
||||||
<Inner>{children}</Inner>
|
</Outer>
|
||||||
</Outer>
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
45
awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx
Normal file
45
awx/ui_next/src/components/Workflow/WorkflowLinkHelp.jsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const GridDL = styled.dl`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content;
|
||||||
|
column-gap: 15px;
|
||||||
|
row-gap: 0px;
|
||||||
|
dt {
|
||||||
|
grid-column-start: 1;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
grid-column-start: 2;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function WorkflowLinkHelp({ link, i18n }) {
|
||||||
|
let linkType;
|
||||||
|
switch (link.edgeType) {
|
||||||
|
case 'always':
|
||||||
|
linkType = i18n._(t`Always`);
|
||||||
|
break;
|
||||||
|
case 'success':
|
||||||
|
linkType = i18n._(t`On Success`);
|
||||||
|
break;
|
||||||
|
case 'failure':
|
||||||
|
linkType = i18n._(t`On Failure`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
linkType = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridDL>
|
||||||
|
<dt>
|
||||||
|
<b>{i18n._(t`Run`)}</b>
|
||||||
|
</dt>
|
||||||
|
<dd>{linkType}</dd>
|
||||||
|
</GridDL>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(WorkflowLinkHelp);
|
||||||
130
awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx
Normal file
130
awx/ui_next/src/components/Workflow/WorkflowNodeHelp.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { secondsToHHMMSS } from '@util/dates';
|
||||||
|
|
||||||
|
const GridDL = styled.dl`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content;
|
||||||
|
column-gap: 15px;
|
||||||
|
row-gap: 0px;
|
||||||
|
dt {
|
||||||
|
grid-column-start: 1;
|
||||||
|
}
|
||||||
|
dd {
|
||||||
|
grid-column-start: 2;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function WorkflowNodeHelp({ node, i18n }) {
|
||||||
|
let nodeType;
|
||||||
|
if (node.unifiedJobTemplate) {
|
||||||
|
switch (node.unifiedJobTemplate.unified_job_type) {
|
||||||
|
case 'job':
|
||||||
|
nodeType = i18n._(t`Job Template`);
|
||||||
|
break;
|
||||||
|
case 'workflow_job':
|
||||||
|
nodeType = i18n._(t`Workflow Job Template`);
|
||||||
|
break;
|
||||||
|
case 'project_update':
|
||||||
|
nodeType = i18n._(t`Project Update`);
|
||||||
|
break;
|
||||||
|
case 'inventory_update':
|
||||||
|
nodeType = i18n._(t`Inventory Update`);
|
||||||
|
break;
|
||||||
|
case 'workflow_approval':
|
||||||
|
nodeType = i18n._(t`Workflow Approval`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nodeType = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobStatus;
|
||||||
|
if (node.job) {
|
||||||
|
switch (node.job.status) {
|
||||||
|
case 'new':
|
||||||
|
jobStatus = i18n._(t`New`);
|
||||||
|
break;
|
||||||
|
case 'pending':
|
||||||
|
jobStatus = i18n._(t`Pending`);
|
||||||
|
break;
|
||||||
|
case 'waiting':
|
||||||
|
jobStatus = i18n._(t`Waiting`);
|
||||||
|
break;
|
||||||
|
case 'running':
|
||||||
|
jobStatus = i18n._(t`Running`);
|
||||||
|
break;
|
||||||
|
case 'successful':
|
||||||
|
jobStatus = i18n._(t`Successful`);
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
jobStatus = i18n._(t`Failed`);
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
jobStatus = i18n._(t`Error`);
|
||||||
|
break;
|
||||||
|
case 'canceled':
|
||||||
|
jobStatus = i18n._(t`Canceled`);
|
||||||
|
break;
|
||||||
|
case 'never updated':
|
||||||
|
jobStatus = i18n._(t`Never Updated`);
|
||||||
|
break;
|
||||||
|
case 'ok':
|
||||||
|
jobStatus = i18n._(t`OK`);
|
||||||
|
break;
|
||||||
|
case 'missing':
|
||||||
|
jobStatus = i18n._(t`Missing`);
|
||||||
|
break;
|
||||||
|
case 'none':
|
||||||
|
jobStatus = i18n._(t`None`);
|
||||||
|
break;
|
||||||
|
case 'updating':
|
||||||
|
jobStatus = i18n._(t`Updating`);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
jobStatus = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<GridDL>
|
||||||
|
{node.unifiedJobTemplate && (
|
||||||
|
<Fragment>
|
||||||
|
<dt>
|
||||||
|
<b>{i18n._(t`Name`)}</b>
|
||||||
|
</dt>
|
||||||
|
<dd>{node.unifiedJobTemplate.name}</dd>
|
||||||
|
<dt>
|
||||||
|
<b>{i18n._(t`Type`)}</b>
|
||||||
|
</dt>
|
||||||
|
<dd>{nodeType}</dd>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
{node.job && (
|
||||||
|
<Fragment>
|
||||||
|
<dt>
|
||||||
|
<b>{i18n._(t`Job Status`)}</b>
|
||||||
|
</dt>
|
||||||
|
<dd>{jobStatus}</dd>
|
||||||
|
{node.job.elapsed && (
|
||||||
|
<Fragment>
|
||||||
|
<dt>
|
||||||
|
<b>{i18n._(t`Elapsed`)}</b>
|
||||||
|
</dt>
|
||||||
|
<dd>{secondsToHHMMSS(node.job.elapsed)}</dd>
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</GridDL>
|
||||||
|
{node.job && (
|
||||||
|
<p css="margin-top: 10px">{i18n._(t`Click to view job details`)}</p>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(WorkflowNodeHelp);
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { PauseIcon } from '@patternfly/react-icons';
|
||||||
|
|
||||||
|
const NodeTypeLetter = styled.foreignObject`
|
||||||
|
font-size: 10px;
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 20px;
|
||||||
|
background-color: #393f43;
|
||||||
|
border-radius: 50%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function WorkflowNodeTypeLetter({ node }) {
|
||||||
|
let nodeTypeLetter;
|
||||||
|
if (node.unifiedJobTemplate && node.unifiedJobTemplate.type) {
|
||||||
|
switch (node.unifiedJobTemplate.type) {
|
||||||
|
case 'job_template':
|
||||||
|
nodeTypeLetter = 'JT';
|
||||||
|
break;
|
||||||
|
case 'project':
|
||||||
|
nodeTypeLetter = 'P';
|
||||||
|
break;
|
||||||
|
case 'inventory_source':
|
||||||
|
nodeTypeLetter = 'I';
|
||||||
|
break;
|
||||||
|
case 'workflow_job_template':
|
||||||
|
nodeTypeLetter = 'W';
|
||||||
|
break;
|
||||||
|
case 'workflow_approval_template':
|
||||||
|
nodeTypeLetter = <PauseIcon />;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nodeTypeLetter = '';
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
node.unifiedJobTemplate &&
|
||||||
|
node.unifiedJobTemplate.unified_job_type
|
||||||
|
) {
|
||||||
|
switch (node.unifiedJobTemplate.unified_job_type) {
|
||||||
|
case 'job':
|
||||||
|
nodeTypeLetter = 'JT';
|
||||||
|
break;
|
||||||
|
case 'project_update':
|
||||||
|
nodeTypeLetter = 'P';
|
||||||
|
break;
|
||||||
|
case 'inventory_update':
|
||||||
|
nodeTypeLetter = 'I';
|
||||||
|
break;
|
||||||
|
case 'workflow_job':
|
||||||
|
nodeTypeLetter = 'W';
|
||||||
|
break;
|
||||||
|
case 'workflow_approval':
|
||||||
|
nodeTypeLetter = <PauseIcon />;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
nodeTypeLetter = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeTypeLetter y="50" x="-10" height="20" width="20">
|
||||||
|
{nodeTypeLetter}
|
||||||
|
</NodeTypeLetter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowNodeTypeLetter;
|
||||||
8
awx/ui_next/src/components/Workflow/index.js
Normal file
8
awx/ui_next/src/components/Workflow/index.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export { default as WorkflowHelp } from './WorkflowHelp';
|
||||||
|
export { default as WorkflowLinkHelp } from './WorkflowLinkHelp';
|
||||||
|
export { default as WorkflowNodeHelp } from './WorkflowNodeHelp';
|
||||||
|
export { default as WorkflowNodeTypeLetter } from './WorkflowNodeTypeLetter';
|
||||||
|
export { default as WorkflowActionTooltip } from './WorkflowActionTooltip';
|
||||||
|
export {
|
||||||
|
default as WorkflowActionTooltipItem,
|
||||||
|
} from './WorkflowActionTooltipItem';
|
||||||
@@ -11,7 +11,9 @@ import RoutedTabs from '@components/RoutedTabs';
|
|||||||
|
|
||||||
import JobDetail from './JobDetail';
|
import JobDetail from './JobDetail';
|
||||||
import JobOutput from './JobOutput';
|
import JobOutput from './JobOutput';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
import WorkflowDetail from './WorkflowDetail';
|
||||||
|
import { WorkflowOutput } from './WorkflowOutput';
|
||||||
|
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||||
|
|
||||||
class Job extends Component {
|
class Job extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -124,12 +126,24 @@ class Job extends Component {
|
|||||||
<Route
|
<Route
|
||||||
key="details"
|
key="details"
|
||||||
path="/jobs/:type/:id/details"
|
path="/jobs/:type/:id/details"
|
||||||
render={() => <JobDetail type={match.params.type} job={job} />}
|
render={() =>
|
||||||
|
match.params.type === 'workflow' ? (
|
||||||
|
<WorkflowDetail job={job} />
|
||||||
|
) : (
|
||||||
|
<JobDetail type={match.params.type} job={job} />
|
||||||
|
)
|
||||||
|
}
|
||||||
/>,
|
/>,
|
||||||
<Route
|
<Route
|
||||||
key="output"
|
key="output"
|
||||||
path="/jobs/:type/:id/output"
|
path="/jobs/:type/:id/output"
|
||||||
render={() => <JobOutput type={match.params.type} job={job} />}
|
render={() =>
|
||||||
|
match.params.type === 'workflow' ? (
|
||||||
|
<WorkflowOutput job={job} />
|
||||||
|
) : (
|
||||||
|
<JobOutput type={match.params.type} job={job} />
|
||||||
|
)
|
||||||
|
}
|
||||||
/>,
|
/>,
|
||||||
<Route
|
<Route
|
||||||
key="not-found"
|
key="not-found"
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ import {
|
|||||||
InventoriesAPI,
|
InventoriesAPI,
|
||||||
AdHocCommandsAPI,
|
AdHocCommandsAPI,
|
||||||
} from '@api';
|
} from '@api';
|
||||||
|
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||||
|
|
||||||
|
const ActionButtonWrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
& > :not(:first-child) {
|
||||||
|
margin-left: 20px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const VariablesInput = styled(_VariablesInput)`
|
const VariablesInput = styled(_VariablesInput)`
|
||||||
.pf-c-form__label {
|
.pf-c-form__label {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import LaunchButton from '@components/LaunchButton';
|
|||||||
import { StatusIcon } from '@components/Sparkline';
|
import { StatusIcon } from '@components/Sparkline';
|
||||||
import { toTitleCase } from '@util/strings';
|
import { toTitleCase } from '@util/strings';
|
||||||
import { formatDateString } from '@util/dates';
|
import { formatDateString } from '@util/dates';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../../constants';
|
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||||
|
|
||||||
const PaddedIcon = styled(StatusIcon)`
|
const PaddedIcon = styled(StatusIcon)`
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { PageSection, Card } from '@patternfly/react-core';
|
|||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { UnifiedJobsAPI } from '@api';
|
import { UnifiedJobsAPI } from '@api';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||||
|
|
||||||
const NOT_FOUND = 'not found';
|
const NOT_FOUND = 'not found';
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Breadcrumbs from '@components/Breadcrumbs/Breadcrumbs';
|
|||||||
import Job from './Job';
|
import Job from './Job';
|
||||||
import JobTypeRedirect from './JobTypeRedirect';
|
import JobTypeRedirect from './JobTypeRedirect';
|
||||||
import JobList from './JobList/JobList';
|
import JobList from './JobList/JobList';
|
||||||
import { JOB_TYPE_URL_SEGMENTS } from '../../constants';
|
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||||
|
|
||||||
class Jobs extends Component {
|
class Jobs extends Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function WorkflowDetail() {
|
||||||
|
return <div>Workflow Detail!</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowDetail;
|
||||||
1
awx/ui_next/src/screens/Job/WorkflowDetail/index.js
Normal file
1
awx/ui_next/src/screens/Job/WorkflowDetail/index.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './WorkflowDetail';
|
||||||
222
awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
Normal file
222
awx/ui_next/src/screens/Job/WorkflowOutput/WorkflowOutput.jsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { CardBody as PFCardBody } from '@patternfly/react-core';
|
||||||
|
import { layoutGraph } from '@util/workflow';
|
||||||
|
import ContentError from '@components/ContentError';
|
||||||
|
import ContentLoading from '@components/ContentLoading';
|
||||||
|
import { WorkflowJobsAPI } from '@api';
|
||||||
|
import WorkflowOutputGraph from './WorkflowOutputGraph';
|
||||||
|
|
||||||
|
const CardBody = styled(PFCardBody)`
|
||||||
|
height: calc(100vh - 240px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Toolbar = styled.div`
|
||||||
|
height: 50px;
|
||||||
|
background-color: grey;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Wrapper = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
height: 100%;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fetchWorkflowNodes = async (jobId, pageNo = 1, nodes = []) => {
|
||||||
|
try {
|
||||||
|
const { data } = await WorkflowJobsAPI.readNodes(jobId, {
|
||||||
|
page_size: 200,
|
||||||
|
page: pageNo,
|
||||||
|
});
|
||||||
|
if (data.next) {
|
||||||
|
return await fetchWorkflowNodes(
|
||||||
|
jobId,
|
||||||
|
pageNo + 1,
|
||||||
|
nodes.concat(data.results)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nodes.concat(data.results);
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function WorkflowOutput({ job, i18n }) {
|
||||||
|
const [contentError, setContentError] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [graphLinks, setGraphLinks] = useState([]);
|
||||||
|
const [graphNodes, setGraphNodes] = useState([]);
|
||||||
|
const [nodePositions, setNodePositions] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const buildGraphArrays = nodes => {
|
||||||
|
const nonRootNodeIds = [];
|
||||||
|
const allNodeIds = [];
|
||||||
|
const arrayOfLinksForChart = [];
|
||||||
|
const nodeIdToChartNodeIdMapping = {};
|
||||||
|
const chartNodeIdToIndexMapping = {};
|
||||||
|
const nodeRef = {};
|
||||||
|
let nodeIdCounter = 1;
|
||||||
|
const arrayOfNodesForChart = [
|
||||||
|
{
|
||||||
|
id: nodeIdCounter,
|
||||||
|
unifiedJobTemplate: {
|
||||||
|
name: i18n._(t`START`),
|
||||||
|
},
|
||||||
|
type: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
nodeIdCounter++;
|
||||||
|
// Assign each node an ID - 0 is reserved for the start node. We need to
|
||||||
|
// make sure that we have an ID on every node including new nodes so the
|
||||||
|
// ID returned by the api won't do
|
||||||
|
nodes.forEach(node => {
|
||||||
|
node.workflowMakerNodeId = nodeIdCounter;
|
||||||
|
nodeRef[nodeIdCounter] = {
|
||||||
|
originalNodeObject: node,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeObj = {
|
||||||
|
index: nodeIdCounter - 1,
|
||||||
|
id: nodeIdCounter,
|
||||||
|
type: 'node',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (node.summary_fields.job) {
|
||||||
|
nodeObj.job = node.summary_fields.job;
|
||||||
|
}
|
||||||
|
if (node.summary_fields.unified_job_template) {
|
||||||
|
nodeRef[nodeIdCounter].unifiedJobTemplate =
|
||||||
|
node.summary_fields.unified_job_template;
|
||||||
|
nodeObj.unifiedJobTemplate = node.summary_fields.unified_job_template;
|
||||||
|
}
|
||||||
|
|
||||||
|
arrayOfNodesForChart.push(nodeObj);
|
||||||
|
allNodeIds.push(node.id);
|
||||||
|
nodeIdToChartNodeIdMapping[node.id] = node.workflowMakerNodeId;
|
||||||
|
chartNodeIdToIndexMapping[nodeIdCounter] = nodeIdCounter - 1;
|
||||||
|
nodeIdCounter++;
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const sourceIndex = chartNodeIdToIndexMapping[node.workflowMakerNodeId];
|
||||||
|
node.success_nodes.forEach(nodeId => {
|
||||||
|
const targetIndex =
|
||||||
|
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
|
||||||
|
arrayOfLinksForChart.push({
|
||||||
|
source: arrayOfNodesForChart[sourceIndex],
|
||||||
|
target: arrayOfNodesForChart[targetIndex],
|
||||||
|
edgeType: 'success',
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
nonRootNodeIds.push(nodeId);
|
||||||
|
});
|
||||||
|
node.failure_nodes.forEach(nodeId => {
|
||||||
|
const targetIndex =
|
||||||
|
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
|
||||||
|
arrayOfLinksForChart.push({
|
||||||
|
source: arrayOfNodesForChart[sourceIndex],
|
||||||
|
target: arrayOfNodesForChart[targetIndex],
|
||||||
|
edgeType: 'failure',
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
nonRootNodeIds.push(nodeId);
|
||||||
|
});
|
||||||
|
node.always_nodes.forEach(nodeId => {
|
||||||
|
const targetIndex =
|
||||||
|
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[nodeId]];
|
||||||
|
arrayOfLinksForChart.push({
|
||||||
|
source: arrayOfNodesForChart[sourceIndex],
|
||||||
|
target: arrayOfNodesForChart[targetIndex],
|
||||||
|
edgeType: 'always',
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
nonRootNodeIds.push(nodeId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueNonRootNodeIds = Array.from(new Set(nonRootNodeIds));
|
||||||
|
|
||||||
|
const rootNodes = allNodeIds.filter(
|
||||||
|
nodeId => !uniqueNonRootNodeIds.includes(nodeId)
|
||||||
|
);
|
||||||
|
|
||||||
|
rootNodes.forEach(rootNodeId => {
|
||||||
|
const targetIndex =
|
||||||
|
chartNodeIdToIndexMapping[nodeIdToChartNodeIdMapping[rootNodeId]];
|
||||||
|
arrayOfLinksForChart.push({
|
||||||
|
source: arrayOfNodesForChart[0],
|
||||||
|
target: arrayOfNodesForChart[targetIndex],
|
||||||
|
edgeType: 'always',
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setGraphNodes(arrayOfNodesForChart);
|
||||||
|
setGraphLinks(arrayOfLinksForChart);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const nodes = await fetchWorkflowNodes(job.id);
|
||||||
|
buildGraphArrays(nodes);
|
||||||
|
} catch (error) {
|
||||||
|
setContentError(error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData();
|
||||||
|
}, [job.id, job.unified_job_template, i18n]);
|
||||||
|
|
||||||
|
// Update positions of nodes/links
|
||||||
|
useEffect(() => {
|
||||||
|
if (graphNodes) {
|
||||||
|
const newNodePositions = {};
|
||||||
|
const g = layoutGraph(graphNodes, graphLinks);
|
||||||
|
|
||||||
|
g.nodes().forEach(node => {
|
||||||
|
newNodePositions[node] = g.node(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
setNodePositions(newNodePositions);
|
||||||
|
}
|
||||||
|
}, [graphLinks, graphNodes]);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<ContentLoading />
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentError) {
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<ContentError error={contentError} />
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardBody>
|
||||||
|
<Wrapper>
|
||||||
|
<Toolbar>Toolbar</Toolbar>
|
||||||
|
{nodePositions && (
|
||||||
|
<WorkflowOutputGraph
|
||||||
|
links={graphLinks}
|
||||||
|
nodes={graphNodes}
|
||||||
|
nodePositions={nodePositions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
</CardBody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(WorkflowOutput);
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import { WorkflowHelp, WorkflowNodeHelp } from '@components/Workflow';
|
||||||
|
import { calcZoomAndFit } from '@util/workflow';
|
||||||
|
import {
|
||||||
|
WorkflowOutputLink,
|
||||||
|
WorkflowOutputNode,
|
||||||
|
WorkflowOutputStartNode,
|
||||||
|
} from '@screens/Job/WorkflowOutput';
|
||||||
|
|
||||||
|
function WorkflowOutputGraph({ links, nodes, nodePositions }) {
|
||||||
|
const [nodeHelp, setNodeHelp] = useState();
|
||||||
|
const svgRef = useRef(null);
|
||||||
|
const gRef = useRef(null);
|
||||||
|
|
||||||
|
// This is the zoom function called by using the mousewheel/click and drag
|
||||||
|
const zoom = () => {
|
||||||
|
const translation = [d3.event.transform.x, d3.event.transform.y];
|
||||||
|
d3.select(gRef.current).attr(
|
||||||
|
'transform',
|
||||||
|
`translate(${translation}) scale(${d3.event.transform.k})`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomRef = d3
|
||||||
|
.zoom()
|
||||||
|
.scaleExtent([0.1, 2])
|
||||||
|
.on('zoom', zoom);
|
||||||
|
|
||||||
|
// Initialize the zoom
|
||||||
|
useEffect(() => {
|
||||||
|
d3.select(svgRef.current).call(zoomRef);
|
||||||
|
}, [zoomRef]);
|
||||||
|
|
||||||
|
// Attempt to zoom the graph to fit the available screen space
|
||||||
|
useEffect(() => {
|
||||||
|
const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current);
|
||||||
|
|
||||||
|
d3.select(svgRef.current).call(
|
||||||
|
zoomRef.transform,
|
||||||
|
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
|
||||||
|
);
|
||||||
|
// We only want this to run once (when the component mounts)
|
||||||
|
// Including zoomRef.transform in the deps array will cause this to
|
||||||
|
// run very frequently.
|
||||||
|
// Discussion: https://github.com/facebook/create-react-app/issues/6880
|
||||||
|
// and https://github.com/facebook/react/issues/15865 amongst others
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{nodeHelp && (
|
||||||
|
<WorkflowHelp>
|
||||||
|
<WorkflowNodeHelp node={nodeHelp} />
|
||||||
|
</WorkflowHelp>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
id="workflow-svg"
|
||||||
|
ref={svgRef}
|
||||||
|
css="display: flex; height: 100%; background-color: #f6f6f6;"
|
||||||
|
>
|
||||||
|
<g id="workflow-g" ref={gRef}>
|
||||||
|
{nodePositions && [
|
||||||
|
<WorkflowOutputStartNode
|
||||||
|
key="start"
|
||||||
|
nodePositions={nodePositions}
|
||||||
|
/>,
|
||||||
|
links.map(link => (
|
||||||
|
<WorkflowOutputLink
|
||||||
|
key={`link-${link.source.id}-${link.target.id}`}
|
||||||
|
link={link}
|
||||||
|
nodePositions={nodePositions}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
nodes.map(node => {
|
||||||
|
if (node.id > 1) {
|
||||||
|
return (
|
||||||
|
<WorkflowOutputNode
|
||||||
|
key={`node-${node.id}`}
|
||||||
|
node={node}
|
||||||
|
nodePositions={nodePositions}
|
||||||
|
mouseEnter={() => setNodeHelp(node)}
|
||||||
|
mouseLeave={() => setNodeHelp(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowOutputGraph;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { generateLine, getLinePoints } from '@util/workflow';
|
||||||
|
|
||||||
|
function WorkflowOutputLink({ link, nodePositions }) {
|
||||||
|
const [pathD, setPathD] = useState();
|
||||||
|
const [pathStroke, setPathStroke] = useState('#CCCCCC');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (link.edgeType === 'failure') {
|
||||||
|
setPathStroke('#d9534f');
|
||||||
|
}
|
||||||
|
if (link.edgeType === 'success') {
|
||||||
|
setPathStroke('#5cb85c');
|
||||||
|
}
|
||||||
|
if (link.edgeType === 'always') {
|
||||||
|
setPathStroke('#337ab7');
|
||||||
|
}
|
||||||
|
}, [link.edgeType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const linePoints = getLinePoints(link, nodePositions);
|
||||||
|
setPathD(generateLine(linePoints));
|
||||||
|
}, [link, nodePositions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
className="WorkflowGraph-link"
|
||||||
|
id={`link-${link.source.id}-${link.target.id}`}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
className="WorkflowGraph-linkPath"
|
||||||
|
d={pathD}
|
||||||
|
stroke={pathStroke}
|
||||||
|
strokeWidth="2px"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowOutputLink;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { StatusIcon } from '@components/Sparkline';
|
||||||
|
import { WorkflowNodeTypeLetter } from '@components/Workflow';
|
||||||
|
import { secondsToHHMMSS } from '@util/dates';
|
||||||
|
import { JOB_TYPE_URL_SEGMENTS } from '@constants';
|
||||||
|
import { constants as wfConstants } from '@util/workflow';
|
||||||
|
|
||||||
|
const NodeG = styled.g`
|
||||||
|
cursor: ${props => (props.job ? 'pointer' : 'default')};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const JobTopLine = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-left: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Elapsed = styled.div`
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 5px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: #ededed;
|
||||||
|
padding: 3px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeContents = styled.foreignObject`
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0px 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeDefaultLabel = styled.p`
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function WorkflowOutputNode({
|
||||||
|
node,
|
||||||
|
nodePositions,
|
||||||
|
mouseEnter,
|
||||||
|
mouseLeave,
|
||||||
|
i18n,
|
||||||
|
}) {
|
||||||
|
let borderColor = '#93969A';
|
||||||
|
|
||||||
|
if (node.job) {
|
||||||
|
if (
|
||||||
|
node.job.status === 'failed' ||
|
||||||
|
node.job.status === 'error' ||
|
||||||
|
node.job.status === 'canceled'
|
||||||
|
) {
|
||||||
|
borderColor = '#d9534f';
|
||||||
|
}
|
||||||
|
if (node.job.status === 'successful' || node.job.status === 'ok') {
|
||||||
|
borderColor = '#5cb85c';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNodeClick = () => {
|
||||||
|
if (node.job) {
|
||||||
|
window.open(
|
||||||
|
`/#/jobs/${JOB_TYPE_URL_SEGMENTS[node.job.type]}/${node.job.id}`,
|
||||||
|
'_blank'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeG
|
||||||
|
id={`node-${node.id}`}
|
||||||
|
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
|
||||||
|
.y - nodePositions[1].y})`}
|
||||||
|
job={node.job}
|
||||||
|
onClick={handleNodeClick}
|
||||||
|
onMouseEnter={mouseEnter}
|
||||||
|
onMouseLeave={mouseLeave}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
width={wfConstants.nodeW}
|
||||||
|
height={wfConstants.nodeH}
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
stroke={borderColor}
|
||||||
|
strokeWidth="2px"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
<NodeContents height="60" width="180">
|
||||||
|
{node.job ? (
|
||||||
|
<Fragment>
|
||||||
|
<JobTopLine>
|
||||||
|
<StatusIcon status={node.job.status} />
|
||||||
|
<p>
|
||||||
|
{node.unifiedJobTemplate
|
||||||
|
? node.unifiedJobTemplate.name
|
||||||
|
: i18n._(t`DELETED`)}
|
||||||
|
</p>
|
||||||
|
</JobTopLine>
|
||||||
|
<Elapsed>
|
||||||
|
<span>{secondsToHHMMSS(node.job.elapsed)}</span>
|
||||||
|
</Elapsed>
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<NodeDefaultLabel>
|
||||||
|
{node.unifiedJobTemplate
|
||||||
|
? node.unifiedJobTemplate.name
|
||||||
|
: i18n._(t`DELETED`)}
|
||||||
|
</NodeDefaultLabel>
|
||||||
|
)}
|
||||||
|
</NodeContents>
|
||||||
|
<circle cy="60" r="10" fill="#393F43" />
|
||||||
|
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
|
||||||
|
</NodeG>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(WorkflowOutputNode);
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { constants as wfConstants } from '@util/workflow';
|
||||||
|
|
||||||
|
function WorkflowOutputStartNode({ nodePositions }) {
|
||||||
|
return (
|
||||||
|
<g id="node-1" transform={`translate(${nodePositions[1].x},0)`}>
|
||||||
|
<rect
|
||||||
|
width={wfConstants.rootW}
|
||||||
|
height={wfConstants.rootH}
|
||||||
|
y="10"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
fill="#0279BC"
|
||||||
|
/>
|
||||||
|
{/* TODO: Translate this...? */}
|
||||||
|
<text x="13" y="30" dy=".35em" fill="white">
|
||||||
|
START
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WorkflowOutputStartNode;
|
||||||
5
awx/ui_next/src/screens/Job/WorkflowOutput/index.js
Normal file
5
awx/ui_next/src/screens/Job/WorkflowOutput/index.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as WorkflowOutput } from './WorkflowOutput';
|
||||||
|
export { default as WorkflowOutputGraph } from './WorkflowOutputGraph';
|
||||||
|
export { default as WorkflowOutputLink } from './WorkflowOutputLink';
|
||||||
|
export { default as WorkflowOutputNode } from './WorkflowOutputNode';
|
||||||
|
export { default as WorkflowOutputStartNode } from './WorkflowOutputStartNode';
|
||||||
@@ -1,767 +0,0 @@
|
|||||||
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
|
||||||
import * as d3 from 'd3';
|
|
||||||
import * as dagre from 'dagre';
|
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import WorkflowHelp from './WorkflowHelp';
|
|
||||||
import WorkflowHelpDetails from './WorkflowHelpDetails';
|
|
||||||
|
|
||||||
const SVG = styled.svg`
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
background-color: #f6f6f6;
|
|
||||||
|
|
||||||
.WorkflowChart-tooltip {
|
|
||||||
padding-left: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-action {
|
|
||||||
height: 25px;
|
|
||||||
width: 25px;
|
|
||||||
font-size: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-action:hover {
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-action:not(:last-of-type) {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-action--add:hover {
|
|
||||||
background-color: #58b957;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-action--edit:hover,
|
|
||||||
.WorkflowChart-action--link:hover,
|
|
||||||
.WorkflowChart-action--details:hover {
|
|
||||||
background-color: #0279bc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-action--delete:hover {
|
|
||||||
background-color: #d9534f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-tooltipArrows {
|
|
||||||
width: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-tooltipArrows--outer {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50% - 10px);
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-right: 10px solid #c4c4c4;
|
|
||||||
border-top: 10px solid transparent;
|
|
||||||
border-bottom: 10px solid transparent;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-tooltipArrows--inner {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(50% - 10px);
|
|
||||||
left: 6px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
border-right: 10px solid white;
|
|
||||||
border-top: 10px solid transparent;
|
|
||||||
border-bottom: 10px solid transparent;
|
|
||||||
margin: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-tooltipActions {
|
|
||||||
background-color: white;
|
|
||||||
border: 1px solid #c4c4c4;
|
|
||||||
border-radius: 2px;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-tooltipContents {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.WorkflowChart-nameText {
|
|
||||||
font-size: 13px;
|
|
||||||
padding: 0px 10px;
|
|
||||||
text-align: center;
|
|
||||||
p {
|
|
||||||
margin-top: 20px;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function Graph({ links, nodes, readOnly, i18n }) {
|
|
||||||
const [helpText, setHelpText] = useState();
|
|
||||||
const svgRef = useRef(null);
|
|
||||||
const gRef = useRef(null);
|
|
||||||
const nodeW = 180;
|
|
||||||
const nodeH = 60;
|
|
||||||
// This needs to be dynamic bc the text can be different lengths in different languages
|
|
||||||
const rootW = 72;
|
|
||||||
const rootH = 40;
|
|
||||||
let currentScale = 1;
|
|
||||||
|
|
||||||
// Dagre is going to shift the root node around as nodes are added/removed
|
|
||||||
// This function ensures that the user doesn't experience that
|
|
||||||
const normalizeY = (nodePositions, y) => {
|
|
||||||
return y - nodePositions[1].y;
|
|
||||||
};
|
|
||||||
|
|
||||||
// This is the zoom function called by using the mousewheel/click and drag
|
|
||||||
const zoom = () => {
|
|
||||||
const translation = [d3.event.transform.x, d3.event.transform.y];
|
|
||||||
d3.select(gRef.current).attr(
|
|
||||||
'transform',
|
|
||||||
`translate(${translation}) scale(${d3.event.transform.k})`
|
|
||||||
);
|
|
||||||
currentScale = d3.event.transform.k;
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomRef = d3
|
|
||||||
.zoom()
|
|
||||||
.scaleExtent([0.1, 2])
|
|
||||||
.on('zoom', zoom);
|
|
||||||
|
|
||||||
// Initialize the zoom
|
|
||||||
useEffect(() => {
|
|
||||||
d3.select(svgRef.current).call(zoomRef);
|
|
||||||
}, [zoomRef]);
|
|
||||||
|
|
||||||
// Draw the graph - this will get triggered whenever
|
|
||||||
// nodes or links changes
|
|
||||||
useEffect(() => {
|
|
||||||
const nodePositions = {};
|
|
||||||
const line = d3
|
|
||||||
.line()
|
|
||||||
.x(d => {
|
|
||||||
return d.x;
|
|
||||||
})
|
|
||||||
.y(d => {
|
|
||||||
return d.y;
|
|
||||||
});
|
|
||||||
const getLinkOverlayPoints = d => {
|
|
||||||
const sourceX =
|
|
||||||
nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1;
|
|
||||||
let sourceY =
|
|
||||||
normalizeY(nodePositions, nodePositions[d.source.id].y) +
|
|
||||||
nodePositions[d.source.id].height / 2;
|
|
||||||
const targetX = nodePositions[d.target.id].x - 1;
|
|
||||||
const targetY =
|
|
||||||
normalizeY(nodePositions, nodePositions[d.target.id].y) +
|
|
||||||
nodePositions[d.target.id].height / 2;
|
|
||||||
|
|
||||||
// There's something off with the math on the root node...
|
|
||||||
if (d.source.id === 1) {
|
|
||||||
sourceY += 10;
|
|
||||||
}
|
|
||||||
const slope = (targetY - sourceY) / (targetX - sourceX);
|
|
||||||
const yIntercept = targetY - slope * targetX;
|
|
||||||
const orthogonalDistance = 8;
|
|
||||||
|
|
||||||
const pt1 = [
|
|
||||||
targetX,
|
|
||||||
slope * targetX +
|
|
||||||
yIntercept +
|
|
||||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
|
||||||
].join(',');
|
|
||||||
const pt2 = [
|
|
||||||
sourceX,
|
|
||||||
slope * sourceX +
|
|
||||||
yIntercept +
|
|
||||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
|
||||||
].join(',');
|
|
||||||
const pt3 = [
|
|
||||||
sourceX,
|
|
||||||
slope * sourceX +
|
|
||||||
yIntercept -
|
|
||||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
|
||||||
].join(',');
|
|
||||||
const pt4 = [
|
|
||||||
targetX,
|
|
||||||
slope * targetX +
|
|
||||||
yIntercept -
|
|
||||||
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
|
||||||
].join(',');
|
|
||||||
|
|
||||||
return [pt1, pt2, pt3, pt4].join(' ');
|
|
||||||
};
|
|
||||||
const lineData = d => {
|
|
||||||
const sourceX =
|
|
||||||
nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1;
|
|
||||||
let sourceY =
|
|
||||||
normalizeY(nodePositions, nodePositions[d.source.id].y) +
|
|
||||||
nodePositions[d.source.id].height / 2;
|
|
||||||
const targetX = nodePositions[d.target.id].x - 1;
|
|
||||||
const targetY =
|
|
||||||
normalizeY(nodePositions, nodePositions[d.target.id].y) +
|
|
||||||
nodePositions[d.target.id].height / 2;
|
|
||||||
|
|
||||||
// There's something off with the math on the root node...
|
|
||||||
if (d.source.id === 1) {
|
|
||||||
sourceY += 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
return line([
|
|
||||||
{
|
|
||||||
x: sourceX,
|
|
||||||
y: sourceY,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
x: targetX,
|
|
||||||
y: targetY,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
const svgGroup = d3.select(gRef.current);
|
|
||||||
|
|
||||||
const g = new dagre.graphlib.Graph();
|
|
||||||
|
|
||||||
g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 120 });
|
|
||||||
|
|
||||||
// This is needed for Dagre
|
|
||||||
g.setDefaultEdgeLabel(() => {
|
|
||||||
return {};
|
|
||||||
});
|
|
||||||
|
|
||||||
nodes.forEach(node => {
|
|
||||||
if (node.id === 1) {
|
|
||||||
g.setNode(node.id, { label: '', width: rootW, height: rootH });
|
|
||||||
} else {
|
|
||||||
g.setNode(node.id, { label: '', width: nodeW, height: nodeH });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
links.forEach(link => {
|
|
||||||
g.setEdge(link.source.id, link.target.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
dagre.layout(g);
|
|
||||||
|
|
||||||
g.nodes().forEach(node => {
|
|
||||||
nodePositions[node] = g.node(node);
|
|
||||||
});
|
|
||||||
|
|
||||||
const linkRefs = svgGroup
|
|
||||||
.selectAll('.WorkflowChart-link')
|
|
||||||
.data(links, d => {
|
|
||||||
return `${d.source.id}-${d.target.id}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove any stale links
|
|
||||||
linkRefs.exit().remove();
|
|
||||||
|
|
||||||
// Add any new links
|
|
||||||
const linkEnter = linkRefs
|
|
||||||
.enter()
|
|
||||||
.append('g')
|
|
||||||
.attr('class', 'WorkflowChart-link')
|
|
||||||
.attr('id', d => `link-${d.source.id}-${d.target.id}`)
|
|
||||||
.attr('stroke-width', '2px');
|
|
||||||
|
|
||||||
linkEnter
|
|
||||||
.append('polygon', 'g')
|
|
||||||
.attr('class', 'WorkflowChart-linkOverlay')
|
|
||||||
.attr('fill', '#E1E1E1')
|
|
||||||
.style('opacity', '0')
|
|
||||||
.attr('id', d => `link-${d.source.id}-${d.target.id}-overlay`)
|
|
||||||
.attr('points', d => getLinkOverlayPoints(d))
|
|
||||||
.on('mouseenter', d => {
|
|
||||||
setHelpText(d);
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add entering links in the parent’s old position.
|
|
||||||
linkEnter
|
|
||||||
.insert('path', 'g')
|
|
||||||
.attr('class', 'WorkflowChart-linkPath')
|
|
||||||
.attr('d', d => lineData(d))
|
|
||||||
.attr('stroke', d => {
|
|
||||||
if (d.edgeType) {
|
|
||||||
if (d.edgeType === 'failure') {
|
|
||||||
return '#d9534f';
|
|
||||||
}
|
|
||||||
if (d.edgeType === 'success') {
|
|
||||||
return '#5cb85c';
|
|
||||||
}
|
|
||||||
if (d.edgeType === 'always') {
|
|
||||||
return '#337ab7';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '#D7D7D7';
|
|
||||||
});
|
|
||||||
|
|
||||||
linkEnter
|
|
||||||
.append('polygon', 'g')
|
|
||||||
.style('opacity', '0')
|
|
||||||
.attr('points', d => getLinkOverlayPoints(d))
|
|
||||||
.on('mouseenter', d => {
|
|
||||||
setHelpText(d);
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
linkEnter
|
|
||||||
.on('mouseenter', d => {
|
|
||||||
d3.select(`#link-${d.source.id}-${d.target.id}`).raise();
|
|
||||||
d3.select(`#link-${d.source.id}-${d.target.id}-overlay`).style(
|
|
||||||
'opacity',
|
|
||||||
'1'
|
|
||||||
);
|
|
||||||
if (!readOnly) {
|
|
||||||
d3
|
|
||||||
.select(`#link-${d.source.id}-${d.target.id}`)
|
|
||||||
.append('foreignObject')
|
|
||||||
.attr('transform', () => {
|
|
||||||
const normalizedSourceY = normalizeY(
|
|
||||||
nodePositions,
|
|
||||||
nodePositions[d.source.id].y
|
|
||||||
);
|
|
||||||
const halfSourceHeight = nodePositions[d.source.id].height / 2;
|
|
||||||
const normalizedTargetY = normalizeY(
|
|
||||||
nodePositions,
|
|
||||||
nodePositions[d.target.id].y
|
|
||||||
);
|
|
||||||
const halfTargetHeight = nodePositions[d.target.id].height / 2;
|
|
||||||
|
|
||||||
let yPos =
|
|
||||||
(normalizedSourceY +
|
|
||||||
halfSourceHeight +
|
|
||||||
normalizedTargetY +
|
|
||||||
halfTargetHeight) /
|
|
||||||
2;
|
|
||||||
|
|
||||||
if (d.source.id === 1) {
|
|
||||||
yPos += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
yPos -= 34;
|
|
||||||
|
|
||||||
return `translate(${(nodePositions[d.source.id].x +
|
|
||||||
nodePositions[d.source.id].width +
|
|
||||||
nodePositions[d.target.id].x) /
|
|
||||||
2}, ${yPos})`;
|
|
||||||
})
|
|
||||||
.attr('width', 52)
|
|
||||||
.attr('height', 68)
|
|
||||||
.attr('class', 'WorkflowChart-tooltip').html(`
|
|
||||||
<div class="WorkflowChart-tooltipContents">
|
|
||||||
<div class="WorkflowChart-tooltipArrows">
|
|
||||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
|
||||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
|
||||||
</div>
|
|
||||||
<div class="WorkflowChart-tooltipActions">
|
|
||||||
<div id="node-add-between" class="WorkflowChart-action WorkflowChart-action--add">
|
|
||||||
<i class="pf-icon pf-icon-add-circle-o"></i>
|
|
||||||
</div>
|
|
||||||
<div id="link-edit" class="WorkflowChart-action WorkflowChart-action--edit">
|
|
||||||
<i class="pf-icon pf-icon-edit"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
d3.select('#node-add-between')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`Add a new node between these two nodes`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
d3.select('#link-edit')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`Edit this link`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.on('mouseleave', d => {
|
|
||||||
d3.select(`#link-${d.source.id}-${d.target.id}`).lower();
|
|
||||||
d3.select(`#link-${d.source.id}-${d.target.id}-overlay`).style(
|
|
||||||
'opacity',
|
|
||||||
'0'
|
|
||||||
);
|
|
||||||
if (!readOnly) {
|
|
||||||
linkEnter.select('.WorkflowChart-tooltip').remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const nodeRefs = svgGroup
|
|
||||||
.selectAll('.WorkflowChart-node')
|
|
||||||
.data(nodes, d => {
|
|
||||||
return d.id;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove any stale nodes
|
|
||||||
nodeRefs.exit().remove();
|
|
||||||
|
|
||||||
// Add new nodes
|
|
||||||
const nodeEnter = nodeRefs
|
|
||||||
.enter()
|
|
||||||
.append('g')
|
|
||||||
.attr('class', 'WorkflowChart-node')
|
|
||||||
.attr('id', d => `node-${d.id}`)
|
|
||||||
.attr(
|
|
||||||
'transform',
|
|
||||||
d =>
|
|
||||||
`translate(${nodePositions[d.id].x},${normalizeY(
|
|
||||||
nodePositions,
|
|
||||||
nodePositions[d.id].y
|
|
||||||
)})`
|
|
||||||
);
|
|
||||||
|
|
||||||
nodeEnter.each((node, i, nodesArray) => {
|
|
||||||
const nodeRef = d3.select(nodesArray[i]);
|
|
||||||
if (node.id === 1) {
|
|
||||||
nodeRef
|
|
||||||
.append('rect')
|
|
||||||
.attr('width', rootW)
|
|
||||||
.attr('height', rootH)
|
|
||||||
.attr('y', 10)
|
|
||||||
.attr('rx', 2)
|
|
||||||
.attr('ry', 2)
|
|
||||||
.attr('fill', '#0279BC')
|
|
||||||
.attr('class', 'WorkflowChart-rootNode');
|
|
||||||
nodeRef
|
|
||||||
.append('text')
|
|
||||||
.attr('x', 13)
|
|
||||||
.attr('y', 30)
|
|
||||||
.attr('dy', '.35em')
|
|
||||||
.attr('fill', 'white')
|
|
||||||
.attr('class', 'WorkflowChart-startText')
|
|
||||||
.text('START');
|
|
||||||
|
|
||||||
if (!readOnly) {
|
|
||||||
nodeRef
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
nodeRef
|
|
||||||
.append('foreignObject')
|
|
||||||
.attr('x', rootW)
|
|
||||||
.attr('y', 11)
|
|
||||||
.attr('width', 52)
|
|
||||||
.attr('height', 37)
|
|
||||||
.attr('class', 'WorkflowChart-tooltip').html(`
|
|
||||||
<div class="WorkflowChart-tooltipContents">
|
|
||||||
<div class="WorkflowChart-tooltipArrows">
|
|
||||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
|
||||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
|
||||||
</div>
|
|
||||||
<div class="WorkflowChart-tooltipActions">
|
|
||||||
<div id="node-add" class="WorkflowChart-action WorkflowChart-action--add">
|
|
||||||
<i class="pf-icon pf-icon-add-circle-o"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
d3.select('#node-add')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`Add a new node`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
nodeRef.select('.WorkflowChart-tooltip').remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
nodeRef
|
|
||||||
.append('rect')
|
|
||||||
.attr('width', nodeW)
|
|
||||||
.attr('height', nodeH)
|
|
||||||
.attr('rx', 2)
|
|
||||||
.attr('ry', 2)
|
|
||||||
.attr('stroke', '#93969A')
|
|
||||||
.attr('stroke-width', '2px')
|
|
||||||
.attr('fill', '#FFFFFF')
|
|
||||||
.attr('class', d => {
|
|
||||||
let classString = 'WorkflowChart-rect';
|
|
||||||
classString += !(d.unifiedJobTemplate && d.unifiedJobTemplate.name)
|
|
||||||
? ' WorkflowChart-dashedNode'
|
|
||||||
: '';
|
|
||||||
return classString;
|
|
||||||
});
|
|
||||||
|
|
||||||
nodeRef
|
|
||||||
.append('foreignObject')
|
|
||||||
.attr('width', nodeW)
|
|
||||||
.attr('height', nodeH)
|
|
||||||
.attr('class', 'WorkflowChart-nameText')
|
|
||||||
.html(
|
|
||||||
d =>
|
|
||||||
`<p>${
|
|
||||||
d.unifiedJobTemplate
|
|
||||||
? d.unifiedJobTemplate.name
|
|
||||||
: i18n._(t`DELETED`)
|
|
||||||
}</p>`
|
|
||||||
);
|
|
||||||
|
|
||||||
nodeRef
|
|
||||||
.append('rect')
|
|
||||||
.attr('width', nodeW)
|
|
||||||
.attr('height', nodeH)
|
|
||||||
.style('opacity', '0')
|
|
||||||
.on('mouseenter', d => {
|
|
||||||
setHelpText(d);
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
nodeRef
|
|
||||||
.append('circle')
|
|
||||||
.attr('cy', nodeH)
|
|
||||||
.attr('r', 10)
|
|
||||||
.attr('class', 'WorkflowChart-nodeTypeCircle')
|
|
||||||
.attr('fill', '#393F43')
|
|
||||||
.style('display', d => (d.unifiedJobTemplate ? null : 'none'));
|
|
||||||
|
|
||||||
nodeRef
|
|
||||||
.append('text')
|
|
||||||
.attr('y', nodeH)
|
|
||||||
.attr('dy', '.35em')
|
|
||||||
.attr('text-anchor', 'middle')
|
|
||||||
.attr('fill', '#FFFFFF')
|
|
||||||
.attr('class', 'WorkflowChart-nodeTypeLetter')
|
|
||||||
.text(d => {
|
|
||||||
let nodeTypeLetter;
|
|
||||||
if (d.unifiedJobTemplate && d.unifiedJobTemplate.type) {
|
|
||||||
switch (d.unifiedJobTemplate.type) {
|
|
||||||
case 'job_template':
|
|
||||||
nodeTypeLetter = 'JT';
|
|
||||||
break;
|
|
||||||
case 'project':
|
|
||||||
nodeTypeLetter = 'P';
|
|
||||||
break;
|
|
||||||
case 'inventory_source':
|
|
||||||
nodeTypeLetter = 'I';
|
|
||||||
break;
|
|
||||||
case 'workflow_job_template':
|
|
||||||
nodeTypeLetter = 'W';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nodeTypeLetter = '';
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
d.unifiedJobTemplate &&
|
|
||||||
d.unifiedJobTemplate.unified_job_type
|
|
||||||
) {
|
|
||||||
switch (d.unifiedJobTemplate.unified_job_type) {
|
|
||||||
case 'job':
|
|
||||||
nodeTypeLetter = 'JT';
|
|
||||||
break;
|
|
||||||
case 'project_update':
|
|
||||||
nodeTypeLetter = 'P';
|
|
||||||
break;
|
|
||||||
case 'inventory_update':
|
|
||||||
nodeTypeLetter = 'I';
|
|
||||||
break;
|
|
||||||
case 'workflow_job':
|
|
||||||
nodeTypeLetter = 'W';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nodeTypeLetter = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nodeTypeLetter;
|
|
||||||
})
|
|
||||||
.style('font-size', '10px')
|
|
||||||
.style('display', d => {
|
|
||||||
return d.unifiedJobTemplate &&
|
|
||||||
d.unifiedJobTemplate.type !== 'workflow_approval_template' &&
|
|
||||||
d.unifiedJobTemplate.unified_job_type !== 'workflow_approval'
|
|
||||||
? null
|
|
||||||
: 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
nodeRef
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
nodeRef.select('.WorkflowChart-rect').attr('stroke', '#007ABC');
|
|
||||||
nodeRef.raise();
|
|
||||||
if (readOnly) {
|
|
||||||
nodeRef
|
|
||||||
.append('foreignObject')
|
|
||||||
.attr('x', nodeW)
|
|
||||||
.attr('y', 11)
|
|
||||||
.attr('width', 52)
|
|
||||||
.attr('height', 37)
|
|
||||||
.attr('class', 'WorkflowChart-tooltip').html(`
|
|
||||||
<div class="WorkflowChart-tooltipContents">
|
|
||||||
<div class="WorkflowChart-tooltipArrows">
|
|
||||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
|
||||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
|
||||||
</div>
|
|
||||||
<div class="WorkflowChart-tooltipActions">
|
|
||||||
<div id="node-details" class="WorkflowChart-action WorkflowChart-action--details">
|
|
||||||
<i class="pf-icon pf-icon-info"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
} else {
|
|
||||||
nodeRef
|
|
||||||
.append('foreignObject')
|
|
||||||
.attr('x', nodeW)
|
|
||||||
.attr('y', -49)
|
|
||||||
.attr('width', 52)
|
|
||||||
.attr('height', 157)
|
|
||||||
.attr('class', 'WorkflowChart-tooltip').html(`
|
|
||||||
<div class="WorkflowChart-tooltipContents">
|
|
||||||
<div class="WorkflowChart-tooltipArrows">
|
|
||||||
<div class="WorkflowChart-tooltipArrows--outer"></div>
|
|
||||||
<div class="WorkflowChart-tooltipArrows--inner"></div>
|
|
||||||
</div>
|
|
||||||
<div class="WorkflowChart-tooltipActions">
|
|
||||||
<div id="node-add" class="WorkflowChart-action WorkflowChart-action--add">
|
|
||||||
<i class="pf-icon pf-icon-add-circle-o"></i>
|
|
||||||
</div>
|
|
||||||
<div id="node-details" class="WorkflowChart-action WorkflowChart-action--details">
|
|
||||||
<i class="pf-icon pf-icon-info"></i>
|
|
||||||
</div>
|
|
||||||
<div id="node-edit" class="WorkflowChart-action WorkflowChart-action--edit">
|
|
||||||
<i class="pf-icon pf-icon-edit"></i>
|
|
||||||
</div>
|
|
||||||
<div id="node-link" class="WorkflowChart-action WorkflowChart-action--link">
|
|
||||||
<i class="pf-icon pf-icon-automation"></i>
|
|
||||||
</div>
|
|
||||||
<div id="node-delete" class="WorkflowChart-action WorkflowChart-action--delete">
|
|
||||||
<i class="pf-icon pf-icon-remove2"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
d3.select('#node-add')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`Add a new node`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
d3.select('#node-edit')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`Edit this node`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
d3.select('#node-link')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`Link to an available node`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
d3.select('#node-delete')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`Remove this node`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
d3.select('#node-details')
|
|
||||||
.on('mouseenter', () => {
|
|
||||||
setHelpText(i18n._(t`View node details`));
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
setHelpText(null);
|
|
||||||
})
|
|
||||||
.on('click', () => {});
|
|
||||||
})
|
|
||||||
.on('mouseleave', () => {
|
|
||||||
nodeRef.select('.WorkflowChart-rect').attr('stroke', '#93969A');
|
|
||||||
nodeRef.select('.WorkflowChart-tooltip').remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// This will make sure that all the link elements appear before the nodes in the dom
|
|
||||||
svgGroup.selectAll('.WorkflowChart-node').order();
|
|
||||||
}, [links, nodes, readOnly, i18n]);
|
|
||||||
|
|
||||||
// Attempt to zoom the graph to fit the available screen space
|
|
||||||
useEffect(() => {
|
|
||||||
// TODO: try to figure out this start node width thing...
|
|
||||||
const startNodeWidth = 60;
|
|
||||||
const gDimensions = d3
|
|
||||||
.select(gRef.current)
|
|
||||||
.node()
|
|
||||||
.getBoundingClientRect();
|
|
||||||
|
|
||||||
const pageHeight = window.innerHeight - 50;
|
|
||||||
const pageWidth = window.innerWidth;
|
|
||||||
|
|
||||||
// For some reason the start node isn't accounted for in the width... add it
|
|
||||||
gDimensions.width += startNodeWidth * currentScale;
|
|
||||||
|
|
||||||
const scaleNeededForMaxHeight =
|
|
||||||
pageHeight / (gDimensions.height / currentScale);
|
|
||||||
const scaleNeededForMaxWidth =
|
|
||||||
pageWidth / (gDimensions.width / currentScale);
|
|
||||||
const lowerScale = Math.min(
|
|
||||||
scaleNeededForMaxHeight,
|
|
||||||
scaleNeededForMaxWidth
|
|
||||||
);
|
|
||||||
|
|
||||||
let scaleToFit;
|
|
||||||
if (lowerScale < 0.5 || lowerScale > 2) {
|
|
||||||
scaleToFit = lowerScale;
|
|
||||||
} else {
|
|
||||||
scaleToFit = Math.floor(lowerScale * 1000) / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
d3.select(svgRef.current).call(
|
|
||||||
zoomRef.transform,
|
|
||||||
d3.zoomIdentity
|
|
||||||
.translate(0, pageHeight / 2 - (nodeH * scaleToFit) / 2)
|
|
||||||
.scale(scaleToFit)
|
|
||||||
);
|
|
||||||
// We only want this to run once (when the component mounts)
|
|
||||||
// but this rule will throw a warning if we don't include
|
|
||||||
// things like height, width, currentScale in the array
|
|
||||||
// of deps. Including them will cause this hook to fire
|
|
||||||
// as those deps change.
|
|
||||||
// Discussion: https://github.com/facebook/create-react-app/issues/6880
|
|
||||||
// and https://github.com/facebook/react/issues/15865 amongst others
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment>
|
|
||||||
{helpText && helpText !== '' && (
|
|
||||||
<WorkflowHelp>
|
|
||||||
{typeof helpText === 'string' && <Fragment>{helpText}</Fragment>}
|
|
||||||
{typeof helpText === 'object' && <WorkflowHelpDetails d={helpText} />}
|
|
||||||
</WorkflowHelp>
|
|
||||||
)}
|
|
||||||
<SVG id="workflow-svg" ref={svgRef}>
|
|
||||||
<g id="workflow-g" ref={gRef} />
|
|
||||||
</SVG>
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withI18n()(Graph);
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import { Button } from '@patternfly/react-core';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import AlertModal from '@components/AlertModal';
|
||||||
|
|
||||||
|
function NodeDeleteModal({ i18n, nodeToDelete, onConfirm, onCancel }) {
|
||||||
|
return (
|
||||||
|
<AlertModal
|
||||||
|
variant="danger"
|
||||||
|
title="Remove Node"
|
||||||
|
isOpen={nodeToDelete}
|
||||||
|
onClose={onCancel}
|
||||||
|
actions={[
|
||||||
|
<Button
|
||||||
|
key="remove"
|
||||||
|
variant="danger"
|
||||||
|
aria-label={i18n._(t`Confirm node removal`)}
|
||||||
|
onClick={() => onConfirm()}
|
||||||
|
>
|
||||||
|
{i18n._(t`Remove`)}
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="cancel"
|
||||||
|
variant="secondary"
|
||||||
|
aria-label={i18n._(t`Cancel node removal`)}
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{i18n._(t`Cancel`)}
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{nodeToDelete && nodeToDelete.unifiedJobTemplate ? (
|
||||||
|
<Fragment>
|
||||||
|
<p>{i18n._(t`Are you sure you want to remove the node below:`)}</p>
|
||||||
|
<br />
|
||||||
|
<strong css="color: var(--pf-global--danger-color--100)">
|
||||||
|
{nodeToDelete.unifiedJobTemplate.name}
|
||||||
|
</strong>
|
||||||
|
</Fragment>
|
||||||
|
) : (
|
||||||
|
<p>{i18n._(t`Are you sure you want to remove this node?`)}</p>
|
||||||
|
)}
|
||||||
|
</AlertModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(NodeDeleteModal);
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { Fragment, useState, useEffect } from 'react';
|
||||||
import { withI18n } from '@lingui/react';
|
import { withI18n } from '@lingui/react';
|
||||||
import { t } from '@lingui/macro';
|
import { t } from '@lingui/macro';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { layoutGraph } from '@util/workflow';
|
||||||
import ContentError from '@components/ContentError';
|
import ContentError from '@components/ContentError';
|
||||||
import ContentLoading from '@components/ContentLoading';
|
import ContentLoading from '@components/ContentLoading';
|
||||||
import Graph from './Graph';
|
import NodeDeleteModal from './Modals/NodeDeleteModal';
|
||||||
import StartScreen from './StartScreen';
|
import VisualizerGraph from './VisualizerGraph';
|
||||||
import Toolbar from './Toolbar';
|
import VisualizerStartScreen from './VisualizerStartScreen';
|
||||||
|
import VisualizerToolbar from './VisualizerToolbar';
|
||||||
import { WorkflowJobTemplatesAPI } from '@api';
|
import { WorkflowJobTemplatesAPI } from '@api';
|
||||||
|
|
||||||
const CenteredContent = styled.div`
|
const CenteredContent = styled.div`
|
||||||
@@ -17,7 +19,7 @@ const CenteredContent = styled.div`
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const VisualizerLayout = styled.div`
|
const Wrapper = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: column;
|
flex-flow: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -48,6 +50,77 @@ function Visualizer({ template, i18n }) {
|
|||||||
const [graphLinks, setGraphLinks] = useState([]);
|
const [graphLinks, setGraphLinks] = useState([]);
|
||||||
// We'll also need to store the original set of nodes...
|
// We'll also need to store the original set of nodes...
|
||||||
const [graphNodes, setGraphNodes] = useState([]);
|
const [graphNodes, setGraphNodes] = useState([]);
|
||||||
|
const [nodePositions, setNodePositions] = useState(null);
|
||||||
|
const [nodeToDelete, setNodeToDelete] = useState(null);
|
||||||
|
|
||||||
|
const deleteNode = () => {
|
||||||
|
const nodeId = nodeToDelete.id;
|
||||||
|
const newGraphNodes = [...graphNodes];
|
||||||
|
const newGraphLinks = [...graphLinks];
|
||||||
|
|
||||||
|
// Remove the node from the array
|
||||||
|
for (let i = newGraphNodes.length; i--; ) {
|
||||||
|
if (newGraphNodes[i].id === nodeId) {
|
||||||
|
newGraphNodes.splice(i, 1);
|
||||||
|
i = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the links
|
||||||
|
const parents = [];
|
||||||
|
const children = [];
|
||||||
|
const linkParentMapping = {};
|
||||||
|
|
||||||
|
// Remove any links that reference this node
|
||||||
|
for (let i = newGraphLinks.length; i--; ) {
|
||||||
|
const link = newGraphLinks[i];
|
||||||
|
|
||||||
|
if (!linkParentMapping[link.target.id]) {
|
||||||
|
linkParentMapping[link.target.id] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
linkParentMapping[link.target.id].push(link.source.id);
|
||||||
|
|
||||||
|
if (link.source.id === nodeId || link.target.id === nodeId) {
|
||||||
|
if (link.source.id === nodeId) {
|
||||||
|
children.push({ id: link.target.id, edgeType: link.edgeType });
|
||||||
|
} else if (link.target.id === nodeId) {
|
||||||
|
parents.push(link.source.id);
|
||||||
|
}
|
||||||
|
newGraphLinks.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the new links
|
||||||
|
parents.forEach(parentId => {
|
||||||
|
children.forEach(child => {
|
||||||
|
if (parentId === 1) {
|
||||||
|
// We only want to create a link from the start node to this node if it
|
||||||
|
// doesn't have any other parents
|
||||||
|
if (linkParentMapping[child.id].length === 1) {
|
||||||
|
newGraphLinks.push({
|
||||||
|
source: { id: parentId },
|
||||||
|
target: { id: child.id },
|
||||||
|
edgeType: 'always',
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (!linkParentMapping[child.id].includes(parentId)) {
|
||||||
|
newGraphLinks.push({
|
||||||
|
source: { id: parentId },
|
||||||
|
target: { id: child.id },
|
||||||
|
edgeType: child.edgeType,
|
||||||
|
type: 'link',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// need to track that this node has been deleted if it's not new
|
||||||
|
|
||||||
|
setNodeToDelete(null);
|
||||||
|
setGraphNodes(newGraphNodes);
|
||||||
|
setGraphLinks(newGraphLinks);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const buildGraphArrays = nodes => {
|
const buildGraphArrays = nodes => {
|
||||||
@@ -170,6 +243,20 @@ function Visualizer({ template, i18n }) {
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [template.id, i18n]);
|
}, [template.id, i18n]);
|
||||||
|
|
||||||
|
// Update positions of nodes/links
|
||||||
|
useEffect(() => {
|
||||||
|
if (graphNodes) {
|
||||||
|
const newNodePositions = {};
|
||||||
|
const g = layoutGraph(graphNodes, graphLinks);
|
||||||
|
|
||||||
|
g.nodes().forEach(node => {
|
||||||
|
newNodePositions[node] = g.node(node);
|
||||||
|
});
|
||||||
|
|
||||||
|
setNodePositions(newNodePositions);
|
||||||
|
}
|
||||||
|
}, [graphLinks, graphNodes]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<CenteredContent>
|
<CenteredContent>
|
||||||
@@ -187,18 +274,27 @@ function Visualizer({ template, i18n }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VisualizerLayout>
|
<Fragment>
|
||||||
<Toolbar template={template} />
|
<Wrapper>
|
||||||
{graphLinks.length > 0 ? (
|
<VisualizerToolbar template={template} />
|
||||||
<Graph
|
{graphLinks.length > 0 ? (
|
||||||
links={graphLinks}
|
<VisualizerGraph
|
||||||
nodes={graphNodes}
|
links={graphLinks}
|
||||||
readOnly={!template.summary_fields.user_capabilities.edit}
|
nodes={graphNodes}
|
||||||
/>
|
nodePositions={nodePositions}
|
||||||
) : (
|
readOnly={!template.summary_fields.user_capabilities.edit}
|
||||||
<StartScreen />
|
onDeleteNodeClick={setNodeToDelete}
|
||||||
)}
|
/>
|
||||||
</VisualizerLayout>
|
) : (
|
||||||
|
<VisualizerStartScreen />
|
||||||
|
)}
|
||||||
|
</Wrapper>
|
||||||
|
<NodeDeleteModal
|
||||||
|
nodeToDelete={nodeToDelete}
|
||||||
|
onConfirm={deleteNode}
|
||||||
|
onCancel={() => setNodeToDelete(null)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { Fragment, useEffect, useRef, useState } from 'react';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import { calcZoomAndFit } from '@util/workflow';
|
||||||
|
import {
|
||||||
|
WorkflowHelp,
|
||||||
|
WorkflowLinkHelp,
|
||||||
|
WorkflowNodeHelp,
|
||||||
|
} from '@components/Workflow';
|
||||||
|
import {
|
||||||
|
VisualizerLink,
|
||||||
|
VisualizerNode,
|
||||||
|
VisualizerStartNode,
|
||||||
|
} from '@screens/Template/WorkflowJobTemplateVisualizer';
|
||||||
|
|
||||||
|
function VizualizerGraph({
|
||||||
|
links,
|
||||||
|
nodes,
|
||||||
|
readOnly,
|
||||||
|
nodePositions,
|
||||||
|
onDeleteNodeClick,
|
||||||
|
}) {
|
||||||
|
const [helpText, setHelpText] = useState(null);
|
||||||
|
const [nodeHelp, setNodeHelp] = useState();
|
||||||
|
const [linkHelp, setLinkHelp] = useState();
|
||||||
|
const svgRef = useRef(null);
|
||||||
|
const gRef = useRef(null);
|
||||||
|
|
||||||
|
// This is the zoom function called by using the mousewheel/click and drag
|
||||||
|
const zoom = () => {
|
||||||
|
const translation = [d3.event.transform.x, d3.event.transform.y];
|
||||||
|
d3.select(gRef.current).attr(
|
||||||
|
'transform',
|
||||||
|
`translate(${translation}) scale(${d3.event.transform.k})`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoomRef = d3
|
||||||
|
.zoom()
|
||||||
|
.scaleExtent([0.1, 2])
|
||||||
|
.on('zoom', zoom);
|
||||||
|
|
||||||
|
// Initialize the zoom
|
||||||
|
useEffect(() => {
|
||||||
|
d3.select(svgRef.current).call(zoomRef);
|
||||||
|
}, [zoomRef]);
|
||||||
|
|
||||||
|
// Attempt to zoom the graph to fit the available screen space
|
||||||
|
useEffect(() => {
|
||||||
|
const [scaleToFit, yTranslate] = calcZoomAndFit(gRef.current);
|
||||||
|
|
||||||
|
d3.select(svgRef.current).call(
|
||||||
|
zoomRef.transform,
|
||||||
|
d3.zoomIdentity.translate(0, yTranslate).scale(scaleToFit)
|
||||||
|
);
|
||||||
|
// We only want this to run once (when the component mounts)
|
||||||
|
// Including zoomRef.transform in the deps array will cause this to
|
||||||
|
// run very frequently.
|
||||||
|
// Discussion: https://github.com/facebook/create-react-app/issues/6880
|
||||||
|
// and https://github.com/facebook/react/issues/15865 amongst others
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{(helpText || nodeHelp || linkHelp) && (
|
||||||
|
<WorkflowHelp>
|
||||||
|
{helpText && <p>{helpText}</p>}
|
||||||
|
{nodeHelp && <WorkflowNodeHelp node={nodeHelp} />}
|
||||||
|
{linkHelp && <WorkflowLinkHelp link={linkHelp} />}
|
||||||
|
</WorkflowHelp>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
id="workflow-svg"
|
||||||
|
ref={svgRef}
|
||||||
|
css="display: flex; height: 100%; background-color: #f6f6f6;"
|
||||||
|
>
|
||||||
|
<g id="workflow-g" ref={gRef}>
|
||||||
|
{nodePositions && [
|
||||||
|
<VisualizerStartNode
|
||||||
|
key="start"
|
||||||
|
nodePositions={nodePositions}
|
||||||
|
readOnly={readOnly}
|
||||||
|
updateHelpText={setHelpText}
|
||||||
|
/>,
|
||||||
|
links.map(link => (
|
||||||
|
<VisualizerLink
|
||||||
|
key={`link-${link.source.id}-${link.target.id}`}
|
||||||
|
link={link}
|
||||||
|
nodePositions={nodePositions}
|
||||||
|
updateHelpText={setHelpText}
|
||||||
|
updateLinkHelp={setLinkHelp}
|
||||||
|
readOnly={readOnly}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
nodes.map(node => {
|
||||||
|
if (node.id > 1) {
|
||||||
|
return (
|
||||||
|
<VisualizerNode
|
||||||
|
key={`node-${node.id}`}
|
||||||
|
node={node}
|
||||||
|
nodePositions={nodePositions}
|
||||||
|
updateHelpText={setHelpText}
|
||||||
|
updateNodeHelp={setNodeHelp}
|
||||||
|
readOnly={readOnly}
|
||||||
|
onDeleteNodeClick={onDeleteNodeClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
]}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VizualizerGraph;
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { PencilAltIcon, PlusIcon, TrashAltIcon } from '@patternfly/react-icons';
|
||||||
|
import {
|
||||||
|
generateLine,
|
||||||
|
getLinkOverlayPoints,
|
||||||
|
getLinePoints,
|
||||||
|
} from '@util/workflow';
|
||||||
|
import {
|
||||||
|
WorkflowActionTooltip,
|
||||||
|
WorkflowActionTooltipItem,
|
||||||
|
} from '@components/Workflow';
|
||||||
|
|
||||||
|
function VisualizerLink({
|
||||||
|
link,
|
||||||
|
nodePositions,
|
||||||
|
readOnly,
|
||||||
|
updateHelpText,
|
||||||
|
updateLinkHelp,
|
||||||
|
i18n,
|
||||||
|
}) {
|
||||||
|
const [hovering, setHovering] = useState(false);
|
||||||
|
const [pathD, setPathD] = useState();
|
||||||
|
const [pathStroke, setPathStroke] = useState('#CCCCCC');
|
||||||
|
const [tooltipX, setTooltipX] = useState();
|
||||||
|
const [tooltipY, setTooltipY] = useState();
|
||||||
|
|
||||||
|
const addNodeAction = (
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="link-add-node"
|
||||||
|
key="add"
|
||||||
|
onMouseEnter={() =>
|
||||||
|
updateHelpText(i18n._(t`Add a new node between these two nodes`))
|
||||||
|
}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</WorkflowActionTooltipItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooltipActions =
|
||||||
|
link.source.id === 1
|
||||||
|
? [addNodeAction]
|
||||||
|
: [
|
||||||
|
addNodeAction,
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="link-edit"
|
||||||
|
key="edit"
|
||||||
|
onMouseEnter={() => updateHelpText(i18n._(t`Edit this link`))}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<PencilAltIcon />
|
||||||
|
</WorkflowActionTooltipItem>,
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="link-delete"
|
||||||
|
key="delete"
|
||||||
|
onMouseEnter={() => updateHelpText(i18n._(t`Delete this link`))}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<TrashAltIcon />
|
||||||
|
</WorkflowActionTooltipItem>,
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleLinkMouseEnter = () => {
|
||||||
|
const linkEl = document.getElementById(
|
||||||
|
`link-${link.source.id}-${link.target.id}`
|
||||||
|
);
|
||||||
|
linkEl.parentNode.appendChild(linkEl);
|
||||||
|
setHovering(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkMouseLeave = () => {
|
||||||
|
const linkEl = document.getElementById(
|
||||||
|
`link-${link.source.id}-${link.target.id}`
|
||||||
|
);
|
||||||
|
linkEl.parentNode.prepend(linkEl);
|
||||||
|
setHovering(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (link.edgeType === 'failure') {
|
||||||
|
setPathStroke('#d9534f');
|
||||||
|
}
|
||||||
|
if (link.edgeType === 'success') {
|
||||||
|
setPathStroke('#5cb85c');
|
||||||
|
}
|
||||||
|
if (link.edgeType === 'always') {
|
||||||
|
setPathStroke('#337ab7');
|
||||||
|
}
|
||||||
|
}, [link.edgeType]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const linePoints = getLinePoints(link, nodePositions);
|
||||||
|
setPathD(generateLine(linePoints));
|
||||||
|
setTooltipX((linePoints[0].x + linePoints[1].x) / 2);
|
||||||
|
setTooltipY((linePoints[0].y + linePoints[1].y) / 2);
|
||||||
|
}, [link, nodePositions]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
className="WorkflowGraph-link"
|
||||||
|
id={`link-${link.source.id}-${link.target.id}`}
|
||||||
|
onMouseEnter={handleLinkMouseEnter}
|
||||||
|
onMouseLeave={handleLinkMouseLeave}
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
id={`link-${link.source.id}-${link.target.id}-overlay`}
|
||||||
|
fill="#E1E1E1"
|
||||||
|
opacity={hovering ? '1' : '0'}
|
||||||
|
points={getLinkOverlayPoints(link, nodePositions)}
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="WorkflowGraph-linkPath"
|
||||||
|
d={pathD}
|
||||||
|
stroke={pathStroke}
|
||||||
|
strokeWidth="2px"
|
||||||
|
/>
|
||||||
|
<polygon
|
||||||
|
opacity="0"
|
||||||
|
points={getLinkOverlayPoints(link, nodePositions)}
|
||||||
|
onMouseEnter={() => updateLinkHelp(link)}
|
||||||
|
onMouseLeave={() => updateLinkHelp(null)}
|
||||||
|
/>
|
||||||
|
{!readOnly && hovering && (
|
||||||
|
<WorkflowActionTooltip
|
||||||
|
pointX={tooltipX}
|
||||||
|
pointY={tooltipY}
|
||||||
|
actions={tooltipActions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(VisualizerLink);
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import {
|
||||||
|
InfoIcon,
|
||||||
|
LinkIcon,
|
||||||
|
PencilAltIcon,
|
||||||
|
PlusIcon,
|
||||||
|
TrashAltIcon,
|
||||||
|
} from '@patternfly/react-icons';
|
||||||
|
import { constants as wfConstants } from '@util/workflow';
|
||||||
|
import {
|
||||||
|
WorkflowActionTooltip,
|
||||||
|
WorkflowActionTooltipItem,
|
||||||
|
WorkflowNodeTypeLetter,
|
||||||
|
} from '@components/Workflow';
|
||||||
|
|
||||||
|
// dont need this in this component
|
||||||
|
const NodeG = styled.g`
|
||||||
|
cursor: ${props => (props.job ? 'pointer' : 'default')};
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeContents = styled.foreignObject`
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0px 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const NodeDefaultLabel = styled.p`
|
||||||
|
margin-top: 20px;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function VisualizerNode({
|
||||||
|
node,
|
||||||
|
nodePositions,
|
||||||
|
updateHelpText,
|
||||||
|
updateNodeHelp,
|
||||||
|
readOnly,
|
||||||
|
i18n,
|
||||||
|
onDeleteNodeClick,
|
||||||
|
}) {
|
||||||
|
const [hovering, setHovering] = useState(false);
|
||||||
|
|
||||||
|
const handleNodeMouseEnter = () => {
|
||||||
|
const nodeEl = document.getElementById(`node-${node.id}`);
|
||||||
|
nodeEl.parentNode.appendChild(nodeEl);
|
||||||
|
setHovering(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewDetailsAction = (
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="node-details"
|
||||||
|
key="details"
|
||||||
|
onMouseEnter={() => updateHelpText(i18n._(t`View node details`))}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<InfoIcon />
|
||||||
|
</WorkflowActionTooltipItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooltipActions = readOnly
|
||||||
|
? [viewDetailsAction]
|
||||||
|
: [
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="node-add"
|
||||||
|
key="add"
|
||||||
|
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</WorkflowActionTooltipItem>,
|
||||||
|
viewDetailsAction,
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="node-edit"
|
||||||
|
key="edit"
|
||||||
|
onMouseEnter={() => updateHelpText(i18n._(t`Edit this node`))}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<PencilAltIcon />
|
||||||
|
</WorkflowActionTooltipItem>,
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="node-link"
|
||||||
|
key="link"
|
||||||
|
onMouseEnter={() =>
|
||||||
|
updateHelpText(i18n._(t`Link to an available node`))
|
||||||
|
}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
</WorkflowActionTooltipItem>,
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="node-delete"
|
||||||
|
key="delete"
|
||||||
|
onMouseEnter={() => updateHelpText(i18n._(t`Delete this node`))}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
onClick={() => onDeleteNodeClick(node)}
|
||||||
|
>
|
||||||
|
<TrashAltIcon />
|
||||||
|
</WorkflowActionTooltipItem>,
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeG
|
||||||
|
id={`node-${node.id}`}
|
||||||
|
transform={`translate(${nodePositions[node.id].x},${nodePositions[node.id]
|
||||||
|
.y - nodePositions[1].y})`}
|
||||||
|
job={node.job}
|
||||||
|
onMouseEnter={handleNodeMouseEnter}
|
||||||
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
width={wfConstants.nodeW}
|
||||||
|
height={wfConstants.nodeH}
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
stroke="#93969A"
|
||||||
|
strokeWidth="2px"
|
||||||
|
fill="#FFFFFF"
|
||||||
|
/>
|
||||||
|
<NodeContents
|
||||||
|
height="60"
|
||||||
|
width="180"
|
||||||
|
onMouseEnter={() => updateNodeHelp(node)}
|
||||||
|
onMouseLeave={() => updateNodeHelp(null)}
|
||||||
|
>
|
||||||
|
<NodeDefaultLabel>
|
||||||
|
{node.unifiedJobTemplate
|
||||||
|
? node.unifiedJobTemplate.name
|
||||||
|
: i18n._(t`DELETED`)}
|
||||||
|
</NodeDefaultLabel>
|
||||||
|
</NodeContents>
|
||||||
|
{node.unifiedJobTemplate && <WorkflowNodeTypeLetter node={node} />}
|
||||||
|
{hovering && (
|
||||||
|
<WorkflowActionTooltip
|
||||||
|
pointX={wfConstants.nodeW}
|
||||||
|
pointY={wfConstants.nodeH / 2}
|
||||||
|
actions={tooltipActions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NodeG>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(VisualizerNode);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { withI18n } from '@lingui/react';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { constants as wfConstants } from '@util/workflow';
|
||||||
|
import {
|
||||||
|
WorkflowActionTooltip,
|
||||||
|
WorkflowActionTooltipItem,
|
||||||
|
} from '@components/Workflow';
|
||||||
|
|
||||||
|
function VisualizerStartNode({
|
||||||
|
updateHelpText,
|
||||||
|
nodePositions,
|
||||||
|
readOnly,
|
||||||
|
i18n,
|
||||||
|
}) {
|
||||||
|
const [hovering, setHovering] = useState(false);
|
||||||
|
|
||||||
|
const handleNodeMouseEnter = () => {
|
||||||
|
const nodeEl = document.getElementById('node-1');
|
||||||
|
nodeEl.parentNode.appendChild(nodeEl);
|
||||||
|
setHovering(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
id="node-1"
|
||||||
|
transform={`translate(${nodePositions[1].x},0)`}
|
||||||
|
onMouseEnter={handleNodeMouseEnter}
|
||||||
|
onMouseLeave={() => setHovering(false)}
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
width={wfConstants.rootW}
|
||||||
|
height={wfConstants.rootH}
|
||||||
|
y="10"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
fill="#0279BC"
|
||||||
|
/>
|
||||||
|
{/* TODO: We need to be able to handle translated text here */}
|
||||||
|
<text x="13" y="30" dy=".35em" fill="white">
|
||||||
|
START
|
||||||
|
</text>
|
||||||
|
{!readOnly && hovering && (
|
||||||
|
<WorkflowActionTooltip
|
||||||
|
pointX={wfConstants.rootW}
|
||||||
|
pointY={wfConstants.rootH / 2 + 10}
|
||||||
|
actions={[
|
||||||
|
<WorkflowActionTooltipItem
|
||||||
|
id="node-add"
|
||||||
|
key="add"
|
||||||
|
onMouseEnter={() => updateHelpText(i18n._(t`Add a new node`))}
|
||||||
|
onMouseLeave={() => updateHelpText(null)}
|
||||||
|
>
|
||||||
|
<i className="pf-icon pf-icon-add-circle-o" />
|
||||||
|
</WorkflowActionTooltipItem>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withI18n()(VisualizerStartNode);
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import React, { Fragment } from 'react';
|
|
||||||
import { withI18n } from '@lingui/react';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
const GridDL = styled.dl`
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: max-content;
|
|
||||||
column-gap: 15px;
|
|
||||||
row-gap: 0px;
|
|
||||||
|
|
||||||
dt {
|
|
||||||
grid-column-start: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
grid-column-start: 2;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function WorkflowHelpDetails({ d, i18n }) {
|
|
||||||
const rows = [];
|
|
||||||
|
|
||||||
if (d.type === 'link') {
|
|
||||||
let linkType;
|
|
||||||
switch (d.edgeType) {
|
|
||||||
case 'always':
|
|
||||||
linkType = i18n._(t`Always`);
|
|
||||||
break;
|
|
||||||
case 'success':
|
|
||||||
linkType = i18n._(t`On Success`);
|
|
||||||
break;
|
|
||||||
case 'failure':
|
|
||||||
linkType = i18n._(t`On Failure`);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
linkType = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
label: i18n._(t`Run`),
|
|
||||||
value: linkType,
|
|
||||||
});
|
|
||||||
} else if (d.type === 'node') {
|
|
||||||
if (d.unifiedJobTemplate) {
|
|
||||||
rows.push({
|
|
||||||
label: i18n._(t`Name`),
|
|
||||||
value: d.unifiedJobTemplate.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
let nodeType;
|
|
||||||
switch (d.unifiedJobTemplate.unified_job_type) {
|
|
||||||
case 'job':
|
|
||||||
nodeType = i18n._(t`Job Template`);
|
|
||||||
break;
|
|
||||||
case 'workflow_job':
|
|
||||||
nodeType = i18n._(t`Workflow Job Template`);
|
|
||||||
break;
|
|
||||||
case 'project_update':
|
|
||||||
nodeType = i18n._(t`Project Update`);
|
|
||||||
break;
|
|
||||||
case 'inventory_update':
|
|
||||||
nodeType = i18n._(t`Inventory Update`);
|
|
||||||
break;
|
|
||||||
case 'workflow_approval':
|
|
||||||
nodeType = i18n._(t`Workflow Approval`);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
nodeType = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.push({
|
|
||||||
label: i18n._(t`Type`),
|
|
||||||
value: nodeType,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// todo: this scenario (deleted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<GridDL>
|
|
||||||
{rows.map(row => (
|
|
||||||
<Fragment key={row.label}>
|
|
||||||
<dt>
|
|
||||||
<b>{row.label}</b>
|
|
||||||
</dt>
|
|
||||||
<dd>{row.value}</dd>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</GridDL>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withI18n()(WorkflowHelpDetails);
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { default as Visualizer } from './Visualizer';
|
export { default as Visualizer } from './Visualizer';
|
||||||
export { default as Toolbar } from './Toolbar';
|
export { default as VisualizerToolbar } from './VisualizerToolbar';
|
||||||
export { default as Graph } from './Graph';
|
export { default as VisualizerGraph } from './VisualizerGraph';
|
||||||
export { default as StartScreen } from './StartScreen';
|
export { default as VisualizerStartScreen } from './VisualizerStartScreen';
|
||||||
export { default as WorkflowHelp } from './WorkflowHelp';
|
export { default as VisualizerStartNode } from './VisualizerStartNode';
|
||||||
export { default as WorkflowHelpDetails } from './WorkflowHelpDetails';
|
export { default as VisualizerLink } from './VisualizerLink';
|
||||||
|
export { default as VisualizerNode } from './VisualizerNode';
|
||||||
|
|||||||
@@ -4,3 +4,7 @@ import { getLanguage } from './language';
|
|||||||
export function formatDateString(dateString, lang = getLanguage(navigator)) {
|
export function formatDateString(dateString, lang = getLanguage(navigator)) {
|
||||||
return new Date(dateString).toLocaleString(lang);
|
return new Date(dateString).toLocaleString(lang);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function secondsToHHMMSS(seconds) {
|
||||||
|
return new Date(seconds * 1000).toISOString().substr(11, 8);
|
||||||
|
}
|
||||||
|
|||||||
174
awx/ui_next/src/util/workflow.jsx
Normal file
174
awx/ui_next/src/util/workflow.jsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import * as dagre from 'dagre';
|
||||||
|
|
||||||
|
const normalizeY = (nodePositions, y) => y - nodePositions[1].y;
|
||||||
|
|
||||||
|
export const constants = {
|
||||||
|
nodeW: 180,
|
||||||
|
nodeH: 60,
|
||||||
|
rootW: 72,
|
||||||
|
rootH: 40,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calcZoomAndFit(gRef) {
|
||||||
|
const gBoundingClientRect = d3
|
||||||
|
.select(gRef)
|
||||||
|
.node()
|
||||||
|
.getBoundingClientRect();
|
||||||
|
|
||||||
|
const gBBoxDimensions = d3
|
||||||
|
.select(gRef)
|
||||||
|
.node()
|
||||||
|
.getBBox();
|
||||||
|
|
||||||
|
const svgElement = document.getElementById('workflow-svg');
|
||||||
|
const svgBoundingClientRect = svgElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
// For some reason the root width needs to be added?
|
||||||
|
gBoundingClientRect.width += constants.rootW;
|
||||||
|
|
||||||
|
const scaleNeededForMaxHeight =
|
||||||
|
svgBoundingClientRect.height / gBoundingClientRect.height;
|
||||||
|
const scaleNeededForMaxWidth =
|
||||||
|
svgBoundingClientRect.width / gBoundingClientRect.width;
|
||||||
|
const lowerScale = Math.min(scaleNeededForMaxHeight, scaleNeededForMaxWidth);
|
||||||
|
|
||||||
|
let scaleToFit;
|
||||||
|
let yTranslate;
|
||||||
|
if (lowerScale < 0.1 || lowerScale > 2) {
|
||||||
|
scaleToFit = lowerScale < 0.1 ? 0.1 : 2;
|
||||||
|
yTranslate =
|
||||||
|
svgBoundingClientRect.height / 2 - (constants.nodeH * scaleToFit) / 2;
|
||||||
|
} else {
|
||||||
|
scaleToFit = Math.floor(lowerScale * 1000) / 1000;
|
||||||
|
yTranslate =
|
||||||
|
(svgBoundingClientRect.height - gBoundingClientRect.height * scaleToFit) /
|
||||||
|
2 -
|
||||||
|
gBBoxDimensions.y * scaleToFit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [scaleToFit, yTranslate];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateLine(points) {
|
||||||
|
const line = d3
|
||||||
|
.line()
|
||||||
|
.x(d => {
|
||||||
|
return d.x;
|
||||||
|
})
|
||||||
|
.y(d => {
|
||||||
|
return d.y;
|
||||||
|
});
|
||||||
|
|
||||||
|
return line(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLinePoints(link, nodePositions) {
|
||||||
|
const sourceX =
|
||||||
|
nodePositions[link.source.id].x + nodePositions[link.source.id].width + 1;
|
||||||
|
let sourceY =
|
||||||
|
normalizeY(nodePositions, nodePositions[link.source.id].y) +
|
||||||
|
nodePositions[link.source.id].height / 2;
|
||||||
|
const targetX = nodePositions[link.target.id].x - 1;
|
||||||
|
const targetY =
|
||||||
|
normalizeY(nodePositions, nodePositions[link.target.id].y) +
|
||||||
|
nodePositions[link.target.id].height / 2;
|
||||||
|
|
||||||
|
// There's something off with the math on the root node...
|
||||||
|
if (link.source.id === 1) {
|
||||||
|
sourceY += 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
x: sourceX,
|
||||||
|
y: sourceY,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: targetX,
|
||||||
|
y: targetY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLinkOverlayPoints(d, nodePositions) {
|
||||||
|
const sourceX =
|
||||||
|
nodePositions[d.source.id].x + nodePositions[d.source.id].width + 1;
|
||||||
|
let sourceY =
|
||||||
|
normalizeY(nodePositions, nodePositions[d.source.id].y) +
|
||||||
|
nodePositions[d.source.id].height / 2;
|
||||||
|
const targetX = nodePositions[d.target.id].x - 1;
|
||||||
|
const targetY =
|
||||||
|
normalizeY(nodePositions, nodePositions[d.target.id].y) +
|
||||||
|
nodePositions[d.target.id].height / 2;
|
||||||
|
|
||||||
|
// There's something off with the math on the root node...
|
||||||
|
if (d.source.id === 1) {
|
||||||
|
sourceY += 10;
|
||||||
|
}
|
||||||
|
const slope = (targetY - sourceY) / (targetX - sourceX);
|
||||||
|
const yIntercept = targetY - slope * targetX;
|
||||||
|
const orthogonalDistance = 8;
|
||||||
|
|
||||||
|
const pt1 = [
|
||||||
|
targetX,
|
||||||
|
slope * targetX +
|
||||||
|
yIntercept +
|
||||||
|
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||||
|
].join(',');
|
||||||
|
const pt2 = [
|
||||||
|
sourceX,
|
||||||
|
slope * sourceX +
|
||||||
|
yIntercept +
|
||||||
|
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||||
|
].join(',');
|
||||||
|
const pt3 = [
|
||||||
|
sourceX,
|
||||||
|
slope * sourceX +
|
||||||
|
yIntercept -
|
||||||
|
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||||
|
].join(',');
|
||||||
|
const pt4 = [
|
||||||
|
targetX,
|
||||||
|
slope * targetX +
|
||||||
|
yIntercept -
|
||||||
|
orthogonalDistance * Math.sqrt(1 + slope * slope),
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
return [pt1, pt2, pt3, pt4].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function layoutGraph(nodes, links) {
|
||||||
|
const g = new dagre.graphlib.Graph();
|
||||||
|
g.setGraph({ rankdir: 'LR', nodesep: 30, ranksep: 120 });
|
||||||
|
|
||||||
|
// This is needed for Dagre
|
||||||
|
g.setDefaultEdgeLabel(() => {
|
||||||
|
return {};
|
||||||
|
});
|
||||||
|
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (node.id === 1) {
|
||||||
|
g.setNode(node.id, {
|
||||||
|
label: '',
|
||||||
|
width: constants.rootW,
|
||||||
|
height: constants.rootH,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
g.setNode(node.id, {
|
||||||
|
label: '',
|
||||||
|
width: constants.nodeW,
|
||||||
|
height: constants.nodeH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
links.forEach(link => {
|
||||||
|
g.setEdge(link.source.id, link.target.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
dagre.layout(g);
|
||||||
|
|
||||||
|
return g;
|
||||||
|
}
|
||||||
@@ -60,6 +60,7 @@ module.exports = {
|
|||||||
alias: {
|
alias: {
|
||||||
'@api': path.join(SRC_PATH, 'api'),
|
'@api': path.join(SRC_PATH, 'api'),
|
||||||
'@components': path.join(SRC_PATH, 'components'),
|
'@components': path.join(SRC_PATH, 'components'),
|
||||||
|
'@constants': path.join(SRC_PATH, 'constants.js'),
|
||||||
'@contexts': path.join(SRC_PATH, 'contexts'),
|
'@contexts': path.join(SRC_PATH, 'contexts'),
|
||||||
'@screens': path.join(SRC_PATH, 'screens'),
|
'@screens': path.join(SRC_PATH, 'screens'),
|
||||||
'@types': path.join(SRC_PATH, 'types'),
|
'@types': path.join(SRC_PATH, 'types'),
|
||||||
|
|||||||
Reference in New Issue
Block a user