diff --git a/cypress/fixtures/build_graph.json b/cypress/fixtures/build_graph.json new file mode 100644 index 000000000..f587738fd --- /dev/null +++ b/cypress/fixtures/build_graph.json @@ -0,0 +1,184 @@ +{ + "build_id": 4, + "nodes": { + "0": { + "id": 0, + "cluster": 0, + "name": "postgres", + "status": "pending", + "started_at": 1698069639, + "finished_at": 1698069655, + "steps": [] + }, + "1": { + "id": 1, + "cluster": 0, + "name": "kafka", + "status": "running", + "started_at": 1698069638, + "finished_at": 1698069655, + "steps": [] + }, + "2": { + "id": 2, + "cluster": 0, + "name": "zookeeper", + "status": "canceled", + "started_at": 1698069638, + "finished_at": 1698069655, + "steps": [] + }, + "3": { + "id": 3, + "cluster": 1, + "name": "init", + "status": "success", + "started_at": 1697565012, + "finished_at": 1697565012, + "steps": [ + { + "id": 1, + "build_id": 4, + "repo_id": 1, + "number": 1, + "name": "init", + "stage": "", + "status": "success", + "error": "", + "exit_code": 1, + "created": 1572029883, + "started": 1572029935, + "finished": 1572029937, + "host": "", + "runtime": "docker", + "distribution": "linux" + } + ] + }, + "4": { + "id": 4, + "cluster": 1, + "name": "clone", + "status": "failure", + "started_at": 1697565012, + "finished_at": 1697565017, + "steps": [ + { + "id": 3, + "build_id": 4, + "repo_id": 1, + "number": 3, + "name": "clone", + "stage": "", + "status": "failure", + "error": "", + "exit_code": 2, + "created": 1572029883, + "started": 1572029928, + "finished": 0, + "host": "", + "runtime": "docker", + "distribution": "linux" + } + ] + }, + "5": { + "id": 5, + "cluster": 1, + "name": "stage-a", + "status": "killed", + "started_at": 1697565017, + "finished_at": 1697565028, + "steps": [ + { + "id": 5, + "build_id": 4, + "repo_id": 1, + "number": 5, + "name": "sleep", + "stage": "", + "status": "killed", + "error": "", + "exit_code": 2, + "created": 1572029883, + "started": 1572029928, + "finished": 0, + "host": "", + "runtime": "docker", + "distribution": "linux" + } + ] + }, + "6": { + "id": 6, + "cluster": 1, + "name": "stage-b", + "status": "running", + "started_at": 1697565017, + "finished_at": 1697565028, + "steps": [ + { + "id": 4, + "build_id": 4, + "repo_id": 1, + "number": 4, + "name": "publish", + "stage": "", + "status": "running", + "error": "", + "exit_code": 2, + "created": 1572029883, + "started": 1572029928, + "finished": 0, + "host": "", + "runtime": "docker", + "distribution": "linux" + } + ] + } + }, + "edges": [ + { + "cluster": 0, + "source": 0, + "destination": 1, + "status": "canceled" + }, + { + "cluster": 0, + "source": 1, + "destination": 2, + "status": "canceled" + }, + { + "cluster": 0, + "source": 2, + "destination": 3, + "status": "canceled" + }, + { + "cluster": 1, + "source": 4, + "destination": 5, + "status": "success" + }, + { + "cluster": 1, + "source": 5, + "destination": 6, + "status": "success" + }, + { + "cluster": 1, + "source": 3, + "destination": 4, + "status": "success" + }, + { + "cluster": 1, + "source": 4, + "destination": 5, + "status": "success" + } + ] +} diff --git a/cypress/integration/graph.spec.js b/cypress/integration/graph.spec.js new file mode 100644 index 000000000..980c831e5 --- /dev/null +++ b/cypress/integration/graph.spec.js @@ -0,0 +1,183 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + */ + +context('Build Graph', () => { + context('logged in and server returning build graph error', () => { + beforeEach(() => { + cy.server(); + cy.stubBuildErrors(); + cy.stubBuildsErrors(); + cy.stubStepsErrors(); + cy.login('/github/octocat/1/graph'); + }); + it('error alert should show', () => { + cy.get('[data-test=alerts]').should('exist').contains('Error'); + }); + }); + context( + 'logged in and server returning a build graph, build and steps', + () => { + beforeEach(() => { + cy.server(); + cy.route('GET', '*api/v1/repos/*/*/builds*', 'fixture:builds_5.json'); + cy.route( + 'GET', + '*api/v1/repos/*/*/builds/*', + 'fixture:build_success.json', + ); + cy.route( + 'GET', + '*api/v1/repos/*/*/builds/*/graph', + 'fixture:build_graph.json', + ); + cy.route('GET', '*api/v1/repos/*/octocat', 'fixture:repository.json'); + cy.login('/github/octocat/4/graph'); + }); + it('build graph root should be visible', () => { + cy.get('.elm-build-graph-root').should('be.visible'); + }); + it('node should reflect build information', () => { + cy.get('.elm-build-graph-node-3').should( + 'have.id', + '#3,init,success,false', + ); + cy.get('.d3-build-graph-node-outline-3').should( + 'have.class', + '-success', + ); + }); + it('edge should contain build information', () => { + cy.get('.elm-build-graph-edge-3-4').should( + 'have.id', + '#3,4,success,false', + ); + cy.get('.d3-build-graph-edge-path-3-4').should( + 'have.class', + '-success', + ); + }); + it('click node should apply focus', () => { + cy.get('.elm-build-graph-node-3') + .should('have.id', '#3,init,success,false') + .within(e => { + cy.get('a').first().click({ force: true }); + }); + cy.get('.elm-build-graph-node-3').should( + 'have.id', + '#3,init,success,true', + ); + cy.get('.d3-build-graph-node-outline-3').should('have.class', '-focus'); + }); + + it('node styles should reflect status', () => { + // services + cy.get('.d3-build-graph-node-outline-0').should( + 'have.class', + '-pending', + ); + cy.get('.d3-build-graph-node-outline-1').should( + 'have.class', + '-running', + ); + cy.get('.d3-build-graph-node-outline-2').should( + 'have.class', + '-canceled', + ); + + // stages + cy.get('.d3-build-graph-node-outline-3').should( + 'have.class', + '-success', + ); + cy.get('.d3-build-graph-node-outline-4').should( + 'have.class', + '-failure', + ); + cy.get('.d3-build-graph-node-outline-5').should( + 'have.class', + '-killed', + ); + }); + it('legend should show', () => { + cy.get('.elm-build-graph-legend').should('be.visible'); + cy.get('.elm-build-graph-legend-node').should('have.length', 7); + }); + it('actions should show', () => { + cy.get('.elm-build-graph-actions').should('be.visible'); + cy.get('[data-test=build-graph-action-toggle-services]').should( + 'be.visible', + ); + cy.get('[data-test=build-graph-action-toggle-steps]').should( + 'be.visible', + ); + cy.get('[data-test=build-graph-action-filter]').should('be.visible'); + cy.get('[data-test=build-graph-action-filter-clear]').should( + 'be.visible', + ); + }); + it('click "show services" should hide services', () => { + cy.get('.elm-build-graph-node-0').should('contain', 'postgres'); + cy.get('[data-test=build-graph-action-toggle-services]') + .should('be.visible') + .click({ force: true }); + cy.get('.elm-build-graph-node-0').should('not.contain', 'postgres'); + cy.get('[data-test=build-graph-action-toggle-services]') + .should('be.visible') + .click({ force: true }); + cy.get('.elm-build-graph-node-0').should('contain', 'postgres'); + }); + it('click "show steps" should hide steps', () => { + cy.get('.elm-build-graph-node-5').should('contain', 'sleep'); + cy.get('[data-test=build-graph-action-toggle-steps]') + .should('be.visible') + .click({ force: true }); + cy.get('.elm-build-graph-node-5').should('not.contain', 'sleep'); + cy.get('[data-test=build-graph-action-toggle-steps]') + .should('be.visible') + .click({ force: true }); + cy.get('.elm-build-graph-node-5').should('contain', 'sleep'); + }); + it('filter input and clear button should control focus', () => { + cy.get('.elm-build-graph-node-5').should( + 'have.id', + '#5,stage-a,killed,false', + ); + cy.get('.d3-build-graph-node-outline-5').should( + 'not.have.class', + '-focus', + ); + cy.get('[data-test=build-graph-action-filter]') + .should('be.visible') + .type('stage-a'); + cy.get('.elm-build-graph-node-5').should( + 'have.id', + '#5,stage-a,killed,true', + ); + cy.get('.d3-build-graph-node-outline-5').should('have.class', '-focus'); + // clear button + cy.get('[data-test=build-graph-action-filter-clear]') + .should('be.visible') + .click({ force: true }); + cy.get('.d3-build-graph-node-outline-5').should( + 'not.have.class', + '-focus', + ); + }); + it('click on step row should redirect to step logs', () => { + cy.location('pathname').should('eq', '/github/octocat/4/graph'); + cy.get('.d3-build-graph-node-step-a').first().click({ force: true }); + cy.location('pathname').should('eq', '/github/octocat/4'); + cy.hash().should('eq', '#step:5'); + }); + it('step should reflect build information', () => { + cy.get('.d3-build-graph-node-step-a svg') + .first() + .should('have.class', '-killed'); + cy.get('.d3-build-graph-node-step-a svg') + .last() + .should('have.class', '-success'); + }); + }, + ); +}); diff --git a/elm.json b/elm.json index 556356b11..658219d95 100644 --- a/elm.json +++ b/elm.json @@ -17,6 +17,8 @@ "elm/svg": "1.0.1", "elm/time": "1.0.0", "elm/url": "1.0.0", + "elm-community/graph": "6.0.0", + "elm-community/json-extra": "4.3.0", "elm-community/list-extra": "8.7.0", "elm-community/maybe-extra": "5.3.0", "elm-community/string-extra": "4.0.1", @@ -29,11 +31,14 @@ "vito/elm-ansi": "9.0.2" }, "indirect": { + "avh4/elm-fifo": "1.0.4", "elm/parser": "1.1.0", "elm/random": "1.0.0", "elm/regex": "1.0.0", "elm/virtual-dom": "1.0.3", - "myrho/elm-round": "1.0.5" + "elm-community/intdict": "3.0.0", + "myrho/elm-round": "1.0.5", + "rtfeldman/elm-iso8601-date-strings": "1.1.4" } }, "test-dependencies": { diff --git a/nginx/default.conf b/nginx/default.conf index 69e21686d..9791d1aaa 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -14,7 +14,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'none'; script-src 'self'; connect-src 'self' $VELA_API; img-src 'self' $VELA_API; style-src 'self'; frame-ancestors 'none'; form-action 'self'" always; + add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' $VELA_API; img-src 'self' $VELA_API; style-src 'self'; frame-ancestors 'none'; form-action 'self'" always; # the following header will break things unless you are running behind a secured (ssl/tls) load balancer or proxy add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; diff --git a/nginx/local.conf b/nginx/local.conf index 768cf1f61..fad868945 100644 --- a/nginx/local.conf +++ b/nginx/local.conf @@ -9,7 +9,7 @@ server { add_header X-Content-Type-Options "nosniff" always; add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'none'; script-src 'self'; connect-src 'self' http://localhost:8080; img-src 'self' http://localhost:8080; style-src 'self'; frame-ancestors 'none'; form-action 'self'" always; + add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; connect-src 'self' http://localhost:8080; img-src 'self' http://localhost:8080; style-src 'self'; frame-ancestors 'none'; form-action 'self'" always; # serve up health endpoint location /health { diff --git a/package-lock.json b/package-lock.json index cbb8ce7f3..ff727b977 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { - "clipboard": "2.0.11" + "@hpcc-js/wasm": "^2.13.0", + "clipboard": "2.0.11", + "d3": "^7.8.5" }, "devDependencies": { "@double-great/stylelint-a11y": "2.0.2", @@ -439,6 +441,17 @@ "@hapi/hoek": "^9.0.0" } }, + "node_modules/@hpcc-js/wasm": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/@hpcc-js/wasm/-/wasm-2.14.1.tgz", + "integrity": "sha512-ncPvj0dEjoye8jD1NAwdThXlZ5mpuGRyRwaNWU6JVBgyT8TewVDmG+RAOXPysyaP1Ui8Lm7562W4KawZtPwa1A==", + "dependencies": { + "yargs": "17.7.2" + }, + "bin": { + "dot-wasm": "bin/dot-wasm.js" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2610,7 +2623,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -3134,6 +3146,54 @@ "tiny-emitter": "^2.0.0" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clone": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", @@ -3166,7 +3226,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -3177,8 +3236,7 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/color-string": { "version": "1.9.1", @@ -3570,6 +3628,384 @@ "cypress": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, + "node_modules/d3": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/d3/-/d3-7.8.5.tgz", + "integrity": "sha512-JgoahDG51ncUfJu6wX/1vWQEqOflgXyl4MaHqlcSruTez7yhaRKR9i8VjjcQGeS2en/jnFivXuaIMnseMMt0XA==", + "dependencies": { + "d3-array": "3", + "d3-axis": "3", + "d3-brush": "3", + "d3-chord": "3", + "d3-color": "3", + "d3-contour": "4", + "d3-delaunay": "6", + "d3-dispatch": "3", + "d3-drag": "3", + "d3-dsv": "3", + "d3-ease": "3", + "d3-fetch": "3", + "d3-force": "3", + "d3-format": "3", + "d3-geo": "3", + "d3-hierarchy": "3", + "d3-interpolate": "3", + "d3-path": "3", + "d3-polygon": "3", + "d3-quadtree": "3", + "d3-random": "3", + "d3-scale": "4", + "d3-scale-chromatic": "3", + "d3-selection": "3", + "d3-shape": "3", + "d3-time": "3", + "d3-time-format": "4", + "d3-timer": "3", + "d3-transition": "3", + "d3-zoom": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-axis": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", + "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-chord": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-3.0.1.tgz", + "integrity": "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==", + "dependencies": { + "d3-path": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-contour": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-4.0.2.tgz", + "integrity": "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==", + "dependencies": { + "d3-array": "^3.2.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==", + "dependencies": { + "commander": "7", + "iconv-lite": "0.6", + "rw": "1" + }, + "bin": { + "csv2json": "bin/dsv2json.js", + "csv2tsv": "bin/dsv2dsv.js", + "dsv2dsv": "bin/dsv2dsv.js", + "dsv2json": "bin/dsv2json.js", + "json2csv": "bin/json2dsv.js", + "json2dsv": "bin/json2dsv.js", + "json2tsv": "bin/json2dsv.js", + "tsv2csv": "bin/dsv2dsv.js", + "tsv2json": "bin/dsv2json.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dsv/node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-fetch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-3.0.1.tgz", + "integrity": "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==", + "dependencies": { + "d3-dsv": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==", + "dependencies": { + "d3-array": "2.5.0 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-hierarchy": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", + "integrity": "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-polygon": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz", + "integrity": "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-random": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-3.0.1.tgz", + "integrity": "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz", + "integrity": "sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -3651,6 +4087,14 @@ "node": ">=0.10.0" } }, + "node_modules/delaunator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.0.tgz", + "integrity": "sha512-AyLvtyJdbv/U1GkiS6gUUzclRoAY4Gs75qkMygJJhU75LW4DNuSF2RMzpxs9jw9Oz1BobHjTdkG3zdP55VxAqw==", + "dependencies": { + "robust-predicates": "^3.0.0" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3898,8 +4342,7 @@ "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/end-of-stream": { "version": "1.4.4", @@ -3944,7 +4387,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", - "dev": true, "engines": { "node": ">=6" } @@ -4395,6 +4837,14 @@ "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "dev": true }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.0.tgz", @@ -4792,6 +5242,17 @@ "node": ">=8.12.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -4872,6 +5333,14 @@ "integrity": "sha512-iKpRpXP+CrP2jyrxvg1kMUpXDyRUFDWurxbnVT1vQPx+Wz9uCYsMIqYuSBLV+PAaZG/d7kRLKRFc9oDMsH+mFQ==", "dev": true }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4927,7 +5396,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -7132,6 +7600,14 @@ "uuid": "bin/uuid" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -7237,6 +7713,11 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -7260,6 +7741,11 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" + }, "node_modules/rxjs": { "version": "6.6.7", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz", @@ -7301,8 +7787,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sass": { "version": "1.62.0", @@ -7630,7 +8115,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -7680,7 +8164,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -7689,7 +8172,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -8713,12 +9195,37 @@ "integrity": "sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==", "dev": true }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yargs-parser": { "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", @@ -8728,6 +9235,14 @@ "node": ">=10" } }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/package.json b/package.json index f924771e4..9af7b9d75 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "node": ">=18.12.0" }, "dependencies": { - "clipboard": "2.0.11" + "clipboard": "2.0.11", + "@hpcc-js/wasm": "^2.13.0", + "d3": "^7.8.5" }, "devDependencies": { "@double-great/stylelint-a11y": "2.0.2", diff --git a/src/elm/Api.elm b/src/elm/Api.elm index 1bfe7cd1e..b7a28e54f 100644 --- a/src/elm/Api.elm +++ b/src/elm/Api.elm @@ -19,6 +19,7 @@ module Api exposing , getAllServices , getAllSteps , getBuild + , getBuildGraph , getBuilds , getCurrentUser , getDeployment @@ -63,6 +64,7 @@ import Vela exposing ( AuthParams , Build + , BuildGraph , BuildNumber , Builds , CurrentUser @@ -94,6 +96,7 @@ import Vela , Templates , Type , decodeBuild + , decodeBuildGraph , decodeBuilds , decodeCurrentUser , decodeDeployment @@ -751,3 +754,15 @@ deleteSchedule : PartialModel a -> Org -> Repo -> ScheduleName -> Request String deleteSchedule model org repo id = delete model.velaAPI (Endpoint.Schedule org repo (Just id) Nothing Nothing) Json.Decode.string |> withAuth model.session + + + +-- GRAPH + + +{-| getBuildGraph : fetches vela build graph by repository and build number +-} +getBuildGraph : PartialModel a -> Org -> Repo -> BuildNumber -> Request BuildGraph +getBuildGraph model org repository buildNumber = + get model.velaAPI (Endpoint.BuildGraph org repository buildNumber) decodeBuildGraph + |> withAuth model.session diff --git a/src/elm/Api/Endpoint.elm b/src/elm/Api/Endpoint.elm index e76b4926b..647629f62 100644 --- a/src/elm/Api/Endpoint.elm +++ b/src/elm/Api/Endpoint.elm @@ -59,6 +59,7 @@ type Endpoint | ServiceLogs Org Repo BuildNumber ServiceNumber | Steps (Maybe Pagination.Page) (Maybe Pagination.PerPage) Org Repo BuildNumber | StepLogs Org Repo BuildNumber StepNumber + | BuildGraph Org Repo BuildNumber | Schedule Org Repo (Maybe ScheduleName) (Maybe Pagination.Page) (Maybe Pagination.PerPage) | Secrets (Maybe Pagination.Page) (Maybe Pagination.PerPage) Engine Type Org Name | Secret Engine Type Org String Name @@ -135,6 +136,9 @@ toUrl api endpoint = StepLogs org repo buildNumber stepNumber -> url api [ "repos", org, repo, "builds", buildNumber, "steps", stepNumber, "logs" ] [] + BuildGraph org repo buildNumber -> + url api [ "repos", org, repo, "builds", buildNumber, "graph" ] [] + Secrets maybePage maybePerPage engine type_ org key -> url api [ "secrets", engine, type_, org, key ] <| Pagination.toQueryParams maybePage maybePerPage diff --git a/src/elm/Crumbs.elm b/src/elm/Crumbs.elm index 86850e64e..26f2b3d3d 100644 --- a/src/elm/Crumbs.elm +++ b/src/elm/Crumbs.elm @@ -387,6 +387,19 @@ toPath page = in [ overviewCrumbLink, orgReposCrumbLink, repoBuildsCrumbLink, schedulesCrumbLink, addCrumbStatic ] + Pages.BuildGraph org repo buildNumber -> + let + orgReposCrumbLink = + ( org, Just <| Pages.OrgRepositories org Nothing Nothing ) + + repoBuildsCrumbLink = + ( repo, Just <| Pages.RepositoryBuilds org repo Nothing Nothing Nothing ) + + buildNumberCrumbStatic = + ( "#" ++ buildNumber, Nothing ) + in + [ overviewCrumbLink, orgReposCrumbLink, repoBuildsCrumbLink, buildNumberCrumbStatic ] + Pages.Login -> [] diff --git a/src/elm/Help/Commands.elm b/src/elm/Help/Commands.elm index ef2ef5c25..c89681a73 100644 --- a/src/elm/Help/Commands.elm +++ b/src/elm/Help/Commands.elm @@ -117,6 +117,9 @@ commands page = Pages.BuildPipeline org repo buildNumber _ _ -> [ viewBuild org repo buildNumber, restartBuild org repo buildNumber ] + Pages.BuildGraph org repo buildNumber -> + [ viewBuild org repo buildNumber, restartBuild org repo buildNumber ] + Pages.RepoSettings org repo -> [ viewRepo org repo, repairRepo org repo, chownRepo org repo ] @@ -896,6 +899,9 @@ resourceLoaded args = Pages.BuildPipeline _ _ _ _ _ -> args.build.success + Pages.BuildGraph org repo _ -> + args.build.success + Pages.AddOrgSecret secretEngine org -> noBlanks [ secretEngine, org ] @@ -992,6 +998,9 @@ resourceLoading args = Pages.BuildPipeline _ _ _ _ _ -> args.build.loading + Pages.BuildGraph _ _ _ -> + args.build.loading + Pages.OrgSecrets _ _ _ _ -> args.secrets.loading diff --git a/src/elm/Interop.elm b/src/elm/Interop.elm index b0f8ea21f..8bb18998d 100644 --- a/src/elm/Interop.elm +++ b/src/elm/Interop.elm @@ -3,7 +3,7 @@ SPDX-License-Identifier: Apache-2.0 --} -port module Interop exposing (onThemeChange, setFavicon, setRedirect, setTheme) +port module Interop exposing (onGraphInteraction, onThemeChange, renderBuildGraph, setFavicon, setRedirect, setTheme) import Json.Decode as Decode import Json.Encode as Encode @@ -39,3 +39,17 @@ port setTheme : Encode.Value -> Cmd msg {-| outbound -} port setFavicon : Encode.Value -> Cmd msg + + + +-- VISUALIZATION + + +{-| outbound +-} +port renderBuildGraph : Encode.Value -> Cmd msg + + +{-| inbound +-} +port onGraphInteraction : (Decode.Value -> msg) -> Sub msg diff --git a/src/elm/Main.elm b/src/elm/Main.elm index 4a0f83569..2684fdaf0 100644 --- a/src/elm/Main.elm +++ b/src/elm/Main.elm @@ -75,6 +75,7 @@ import Maybe.Extra exposing (unwrap) import Nav exposing (viewUtil) import Pager import Pages exposing (Page) +import Pages.Build.Graph.Interop exposing (renderBuildGraph) import Pages.Build.Logs exposing ( addLog @@ -127,6 +128,7 @@ import Vela exposing ( AuthParams , Build + , BuildGraph , BuildModel , BuildNumber , Builds @@ -139,6 +141,7 @@ import Vela , Favicon , Field , FocusFragment + , GraphInteraction , HookNumber , Hooks , Key @@ -177,6 +180,7 @@ import Vela , buildUpdateRepoBoolPayload , buildUpdateRepoIntPayload , buildUpdateRepoStringPayload + , decodeGraphInteraction , decodeTheme , defaultEnableRepositoryPayload , defaultFavicon @@ -190,8 +194,13 @@ import Vela , isComplete , secretTypeToString , statusToFavicon + , stringToStatus , stringToTheme , updateBuild + , updateBuildGraph + , updateBuildGraphFilter + , updateBuildGraphShowServices + , updateBuildGraphShowSteps , updateBuildNumber , updateBuildPipelineConfig , updateBuildPipelineExpand @@ -229,8 +238,10 @@ import Vela , updateRepoEnabling , updateRepoInitialized , updateRepoLimit + , updateRepoModels , updateRepoTimeout ) +import Visualization.DOT as DOT @@ -405,6 +416,12 @@ type Msg | FollowService Int | ShowHideTemplates | FocusPipelineConfigLineNumber Int + | BuildGraphShowServices Bool + | BuildGraphShowSteps Bool + | BuildGraphRefresh Org Repo BuildNumber + | BuildGraphRotate + | BuildGraphUpdateFilter String + | OnBuildGraphInteraction GraphInteraction -- Outgoing HTTP requests | RefreshAccessToken | SignInRequested @@ -472,6 +489,8 @@ type Msg | AddScheduleResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Schedule )) | UpdateScheduleResponse (Result (Http.Detailed.Error String) ( Http.Metadata, Schedule )) | DeleteScheduleResponse (Result (Http.Detailed.Error String) ( Http.Metadata, String )) + -- Graph + | BuildGraphResponse Org Repo BuildNumber Bool (Result (Http.Detailed.Error String) ( Http.Metadata, BuildGraph )) -- Time | AdjustTimeZone Zone | AdjustTime Posix @@ -498,6 +517,12 @@ update msg model = rm = model.repo + bm = + rm.build + + gm = + model.repo.build.graph + sm = model.schedulesModel @@ -938,6 +963,123 @@ update msg model = , Navigation.pushUrl model.navigationKey <| url ) + BuildGraphRefresh org repo buildNumber -> + let + ugm = + { gm + | graph = Loading + } + + um_ = + updateRepoModels model rm bm ugm + in + ( um_ + , getBuildGraph um_ org repo buildNumber True + ) + + BuildGraphRotate -> + let + rankdir = + case gm.rankdir of + DOT.LR -> + DOT.TB + + _ -> + DOT.LR + + ugm = + { gm + | rankdir = rankdir + } + + um_ = + updateRepoModels model rm bm ugm + in + ( um_ + , renderBuildGraph um_ False + ) + + BuildGraphUpdateFilter filter -> + let + ugm = + { gm + | filter = String.toLower filter + } + + um_ = + updateRepoModels model rm bm ugm + in + ( um_ + , renderBuildGraph um_ False + ) + + BuildGraphShowServices show -> + let + ugm = + { gm + | showServices = show + } + + um_ = + updateRepoModels model rm bm ugm + in + ( um_ + , renderBuildGraph um_ False + ) + + BuildGraphShowSteps show -> + let + ugm = + { gm + | showSteps = show + } + + um_ = + updateRepoModels model rm bm ugm + in + ( um_ + , renderBuildGraph um_ False + ) + + OnBuildGraphInteraction interaction -> + let + ( ugm_, cmd ) = + case interaction.eventType of + "href" -> + ( model.repo.build.graph + , Util.dispatch <| FocusOn (focusFragmentToFocusId "step" (Just <| String.Extra.rightOf "#" interaction.href)) + ) + + "backdrop_click" -> + let + ugm = + { gm | focusedNode = -1 } + + um_ = + updateRepoModels model rm bm ugm + in + ( ugm, renderBuildGraph um_ False ) + + "node_click" -> + let + ugm = + { gm | focusedNode = Maybe.withDefault -1 <| String.toInt interaction.nodeID } + + um_ = + updateRepoModels model rm bm ugm + in + ( ugm, renderBuildGraph um_ False ) + + _ -> + ( model.repo.build.graph, Cmd.none ) + in + ( updateRepoModels model rm bm ugm_ + , Cmd.batch + [ Navigation.pushUrl model.navigationKey interaction.href + , cmd + ] + ) + -- Outgoing HTTP requests RefreshAccessToken -> ( model, getToken model ) @@ -2001,6 +2143,42 @@ update msg model = Err error -> ( model, addError error ) + BuildGraphResponse _ _ buildNumber _ response -> + case response of + Ok ( _, g ) -> + case model.page of + Pages.BuildGraph _ _ _ -> + let + sameBuild = + gm.buildNumber == buildNumber + + ugm = + { gm | buildNumber = buildNumber, graph = RemoteData.succeed g } + + updatedModel = + updateRepoModels model rm bm ugm + + cmd = + if not sameBuild then + renderBuildGraph updatedModel False + + else + Cmd.none + in + ( updatedModel + , cmd + ) + + _ -> + ( model + , Cmd.none + ) + + Err error -> + ( { model | repo = { rm | build = { bm | graph = { gm | graph = toFailure error } } } } + , addError error + ) + -- Time AdjustTimeZone newZone -> ( { model | zone = newZone } @@ -2016,10 +2194,15 @@ update msg model = case interval of OneSecond -> let - ( favicon, cmd ) = + ( favicon, updateFavicon ) = refreshFavicon model.page model.favicon rm.build.build in - ( { model | time = time, favicon = favicon }, cmd ) + ( { model | time = time, favicon = favicon } + , Cmd.batch + [ updateFavicon + , refreshRenderBuildGraph model + ] + ) FiveSecond -> ( model, refreshPage model ) @@ -2199,6 +2382,7 @@ subscriptions : Model -> Sub Msg subscriptions model = Sub.batch <| [ Interop.onThemeChange decodeOnThemeChange + , Interop.onGraphInteraction decodeOnGraphInteraction , onMouseDown "contextual-help" model ShowHideHelp , onMouseDown "identity" model ShowHideIdentity , onMouseDown "build-actions" model (ShowHideBuildMenu Nothing) @@ -2219,6 +2403,16 @@ decodeOnThemeChange inTheme = SetTheme Dark +decodeOnGraphInteraction : Decode.Value -> Msg +decodeOnGraphInteraction interaction = + case Decode.decodeValue decodeGraphInteraction interaction of + Ok interaction_ -> + OnBuildGraphInteraction interaction_ + + Err _ -> + NoOp + + {-| refreshSubscriptions : takes model and returns the subscriptions for automatically refreshing page data -} refreshSubscriptions : Model -> Sub Msg @@ -2318,6 +2512,13 @@ refreshPage model = , refreshBuild model org repo buildNumber ] + Pages.BuildGraph org repo buildNumber -> + Cmd.batch + [ getBuilds model org repo Nothing Nothing Nothing + , refreshBuild model org repo buildNumber + , refreshBuildGraph model org repo buildNumber + ] + Pages.Hooks org repo maybePage maybePerPage -> getHooks model org repo maybePage maybePerPage @@ -2405,6 +2606,29 @@ refreshBuildServices model org repo buildNumber focusFragment = Cmd.none +{-| refreshBuildGraph : takes model org repo and build number and refreshes the build graph if necessary +-} +refreshBuildGraph : Model -> Org -> Repo -> BuildNumber -> Cmd Msg +refreshBuildGraph model org repo buildNumber = + if shouldRefresh model.page model.repo.build then + getBuildGraph model org repo buildNumber True + + else + Cmd.none + + +{-| refreshRenderBuildGraph : takes model and refreshes the build graph render if necessary +-} +refreshRenderBuildGraph : Model -> Cmd Msg +refreshRenderBuildGraph model = + case model.page of + Pages.BuildGraph _ _ _ -> + renderBuildGraph model False + + _ -> + Cmd.none + + {-| shouldRefresh : takes build and returns true if a refresh is required -} shouldRefresh : Page -> BuildModel -> Bool @@ -2447,6 +2671,22 @@ shouldRefresh page build = Loading -> False + -- check graph nodes when viewing graph tab + Pages.BuildGraph _ _ _ -> + case build.graph.graph of + Success graph -> + List.any (\( _, n ) -> not <| isComplete (stringToStatus n.status)) (Dict.toList graph.nodes) + + -- do not use unsuccessful states to dictate refresh + NotAsked -> + False + + Failure _ -> + False + + Loading -> + False + _ -> False ) @@ -2880,6 +3120,16 @@ viewContent model = buildNumber ) + Pages.BuildGraph org repo buildNumber -> + ( "Visualize " ++ String.join "/" [ org, repo, buildNumber ] + , Pages.Build.View.viewBuildGraph + model + buildMsgs + org + repo + buildNumber + ) + Pages.Settings -> ( "Settings" , Pages.Settings.view model.session model.time (Pages.Settings.Msgs Copy) @@ -3180,6 +3430,9 @@ setNewPage route model = ( Routes.BuildPipeline org repo buildNumber expand lineFocus, Authenticated _ ) -> loadBuildPipelinePage model org repo buildNumber expand lineFocus + ( Routes.BuildGraph org repo buildNumber, Authenticated _ ) -> + loadBuildGraphPage model org repo buildNumber + ( Routes.AddSchedule org repo, Authenticated _ ) -> loadAddSchedulePage model org repo @@ -4056,6 +4309,78 @@ loadBuildPage model org repo buildNumber lineFocus = ) +{-| loadBuildGraphPage : takes model org, repo, and build number and loads the appropriate build graph resources. +-} +loadBuildGraphPage : Model -> Org -> Repo -> BuildNumber -> ( Model, Cmd Msg ) +loadBuildGraphPage model org repo buildNumber = + let + -- get resource transition information + sameBuild = + isSameBuild ( org, repo, buildNumber ) model.page + + sameResource = + case model.page of + Pages.BuildGraph _ _ _ -> + True + + _ -> + False + + rm = + model.repo + + bm = + rm.build + + gm = + bm.graph + + graph = + if sameBuild then + RemoteData.unwrap RemoteData.Loading (\g_ -> RemoteData.succeed g_) gm.graph + + else + RemoteData.Loading + + -- if build has changed, set build fields in the model + mm = + if not sameBuild then + setBuild org repo buildNumber sameResource model + + else + model + + focusedNode = + if sameBuild then + gm.focusedNode + + else + -1 + + um = + { mm + | page = Pages.BuildGraph org repo buildNumber + , repo = { rm | build = { bm | graph = { gm | graph = graph, focusedNode = focusedNode } } } + } + in + ( um + -- do not load resources if transition is auto refresh, line focus, etc + -- MUST render graph here, or clicking on nodes won't cause an immediate change + , if sameBuild && sameResource then + renderBuildGraph um False + + else + Cmd.batch + [ getRepo um org repo + , getBuilds um org repo Nothing Nothing Nothing + , getBuild um org repo buildNumber + , getAllBuildSteps um org repo buildNumber Nothing False + , getBuildGraph um org repo buildNumber False + , renderBuildGraph um True + ] + ) + + {-| loadBuildServicesPage : takes model org, repo, and build number and loads the appropriate build services. -} loadBuildServicesPage : Model -> Org -> Repo -> BuildNumber -> FocusFragment -> ( Model, Cmd Msg ) @@ -4233,6 +4558,9 @@ isSameBuild id currentPage = Pages.BuildPipeline o r b _ _ -> not <| resourceChanged id ( o, r, b ) + Pages.BuildGraph o r b -> + not <| resourceChanged id ( o, r, b ) + _ -> False @@ -4245,6 +4573,9 @@ setBuild org repo buildNumber soft model = rm = model.repo + gm = + rm.build.graph + pipeline = model.pipeline in @@ -4277,6 +4608,10 @@ setBuild org repo buildNumber soft model = |> updateBuildServicesFollowing 0 |> updateBuildServicesLogs [] |> updateBuildServicesFocusFragment Nothing + |> updateBuildGraph NotAsked + |> updateBuildGraphShowServices gm.showServices + |> updateBuildGraphShowSteps gm.showSteps + |> updateBuildGraphFilter gm.filter } @@ -4529,6 +4864,13 @@ buildMsgs = , followStep = FollowStep , followService = FollowService } + , buildGraphMsgs = + { refresh = BuildGraphRefresh + , rotate = BuildGraphRotate + , showServices = BuildGraphShowServices + , showSteps = BuildGraphShowSteps + , updateFilter = BuildGraphUpdateFilter + } } @@ -4630,6 +4972,11 @@ getBuildAndPipeline model org repo buildNumber expand = Api.try (BuildAndPipelineResponse org repo expand) <| Api.getBuild model org repo buildNumber +getBuildGraph : Model -> Org -> Repo -> BuildNumber -> Bool -> Cmd Msg +getBuildGraph model org repo buildNumber refresh = + Api.try (BuildGraphResponse org repo buildNumber refresh) <| Api.getBuildGraph model org repo buildNumber + + getDeployment : Model -> Org -> Repo -> DeploymentId -> Cmd Msg getDeployment model org repo deploymentNumber = Api.try DeploymentResponse <| Api.getDeployment model org repo <| Just deploymentNumber diff --git a/src/elm/Nav.elm b/src/elm/Nav.elm index 40c2d80b9..2c776b2f6 100644 --- a/src/elm/Nav.elm +++ b/src/elm/Nav.elm @@ -165,6 +165,12 @@ navButtons model { fetchSourceRepos, toggleFavorite, restartBuild, cancelBuild } , restartBuildButton org repo model.repo.build.build restartBuild ] + Pages.BuildGraph org repo _ -> + div [ class "buttons" ] + [ cancelBuildButton org repo model.repo.build.build cancelBuild + , restartBuildButton org repo model.repo.build.build restartBuild + ] + Pages.Hooks org repo _ _ -> starToggle org repo toggleFavorite <| isFavorited model.user <| org ++ "/" ++ repo @@ -269,6 +275,9 @@ viewUtil model = Pages.BuildPipeline _ _ _ _ _ -> Pages.Build.History.view model.time model.zone model.page 10 model.repo + Pages.BuildGraph _ _ _ -> + Pages.Build.History.view model.time model.zone model.page 10 model.repo + Pages.AddDeployment _ _ -> text "" @@ -470,6 +479,7 @@ viewBuildTabs model org repo buildNumber currentPage = [ Tab "Build" currentPage (Pages.Build org repo buildNumber bm.steps.focusFragment) False True , Tab "Services" currentPage (Pages.BuildServices org repo buildNumber bm.services.focusFragment) False True , Tab "Pipeline" currentPage (Pages.BuildPipeline org repo buildNumber pipeline.expand pipeline.focusFragment) False True + , Tab "Visualize" currentPage (Pages.BuildGraph org repo buildNumber) False True ] in viewTabs tabs "jump-bar-build" diff --git a/src/elm/Pages.elm b/src/elm/Pages.elm index 079cc4479..a90aa7d7e 100644 --- a/src/elm/Pages.elm +++ b/src/elm/Pages.elm @@ -36,6 +36,7 @@ type Page | Build Org Repo BuildNumber FocusFragment | BuildServices Org Repo BuildNumber FocusFragment | BuildPipeline Org Repo BuildNumber (Maybe ExpandTemplatesQuery) (Maybe Fragment) + | BuildGraph Org Repo BuildNumber | AddSchedule Org Repo | Schedule Org Repo ScheduleName | Schedules Org Repo (Maybe Pagination.Page) (Maybe Pagination.PerPage) @@ -125,6 +126,9 @@ toRoute page = BuildPipeline org repo buildNumber expanded lineFocus -> Routes.BuildPipeline org repo buildNumber expanded lineFocus + BuildGraph org repo buildNumber -> + Routes.BuildGraph org repo buildNumber + AddSchedule org repo -> Routes.AddSchedule org repo @@ -221,6 +225,9 @@ strip page = BuildPipeline org repo buildNumber _ _ -> BuildPipeline org repo buildNumber Nothing Nothing + BuildGraph org repo buildNumber -> + BuildGraph org repo buildNumber + AddSchedule org repo -> AddSchedule org repo diff --git a/src/elm/Pages/Build/Graph/DOT.elm b/src/elm/Pages/Build/Graph/DOT.elm new file mode 100644 index 000000000..185f3b335 --- /dev/null +++ b/src/elm/Pages/Build/Graph/DOT.elm @@ -0,0 +1,508 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Build.Graph.DOT exposing (renderDOT) + +import Dict exposing (Dict) +import Focus +import Graph exposing (Edge, Node) +import Pages.Build.Model as BuildModel +import RemoteData exposing (RemoteData(..)) +import Routes exposing (Route(..)) +import Util +import Vela + exposing + ( Build + , BuildGraph + , BuildGraphEdge + , BuildGraphNode + , Repository + , statusToString + ) +import Visualization.DOT + exposing + ( Attribute(..) + , AttributeValue(..) + , Rankdir(..) + , Styles + , clusterSubgraph + , digraph + , escapeAttributes + , makeAttributes + ) + + +{-| renderDOT : takes model and build graph, and returns a string representation of a DOT graph using the extended Graph DOT package + + +-} +renderDOT : BuildModel.PartialModel a -> Repository -> Build -> BuildGraph -> String +renderDOT model repo build buildGraph = + let + isNodeFocused : String -> BuildGraphNode -> Bool + isNodeFocused filter n = + n.id + == model.repo.build.graph.focusedNode + || (String.length filter > 2) + && (String.contains filter n.name + || List.any (\s -> String.contains filter s.name) n.steps + ) + + isEdgeFocused : Int -> BuildGraphEdge -> Bool + isEdgeFocused focusedNode e = + focusedNode == e.destination || focusedNode == e.source + + -- convert BuildGraphNode to Graph.Node + inNodes = + buildGraph.nodes + |> Dict.toList + |> List.map + (\( _, n ) -> + Node n.id + (BuildGraphNode n.cluster n.id n.name n.status n.startedAt n.finishedAt n.steps (isNodeFocused model.repo.build.graph.filter n)) + ) + + -- convert BuildGraphEdge to Graph.Edge + inEdges = + buildGraph.edges + |> List.map + (\e -> Edge e.source e.destination (BuildGraphEdge e.cluster e.source e.destination e.status (isEdgeFocused model.repo.build.graph.focusedNode e))) + + -- construct a Graph to extract nodes and edges + ( nodes, edges ) = + Graph.fromNodesAndEdges inNodes inEdges + |> (\graph -> ( Graph.nodes graph, Graph.edges graph )) + + -- group nodes based on cluster + builtInNodes = + nodes + |> List.filter (\n -> n.label.cluster == builtInClusterID) + + pipelineNodes = + nodes + |> List.filter (\n -> n.label.cluster == pipelineClusterID) + + serviceNodes = + nodes + |> List.filter (\n -> n.label.cluster == serviceClusterID) + + -- group edges based on cluster + builtInEdges = + edges + |> List.filter (\e -> e.label.cluster == builtInClusterID) + + pipelineEdges = + edges + |> List.filter (\e -> e.label.cluster == pipelineClusterID) + + serviceEdges = + edges + |> List.filter (\e -> e.label.cluster == serviceClusterID) + + -- convert nodes and edges to DOT string format + builtInNodesString = + List.map (nodeToString model repo build) builtInNodes + |> String.join "\n" + + pipelineNodesString = + List.map (nodeToString model repo build) pipelineNodes + |> String.join "\n" + + serviceNodesString = + List.map (nodeToString model repo build) serviceNodes + |> String.join "\n" + + builtInEdgesString = + List.map edgeToString builtInEdges + |> String.join "\n" + + pipelineEdgesString = + List.map edgeToString pipelineEdges + |> String.join "\n" + + serviceEdgesString = + List.map edgeToString serviceEdges + |> String.join "\n" + + -- construct DOT subgraphs using nodes and edges + pipelineSubgraph = + clusterSubgraph pipelineClusterID pipelineSubgraphStyles pipelineNodesString pipelineEdgesString + + builtInSubgraph = + clusterSubgraph builtInClusterID builtInSubgraphStyles builtInNodesString builtInEdgesString + + serviceSubgraph = + if model.repo.build.graph.showServices then + clusterSubgraph serviceClusterID serviceSubgraphStyles serviceNodesString serviceEdgesString + + else + "" + + -- reverse the subgraphs for top-bottom rankdir to consistently group services and built-ins + rotation = + case model.repo.build.graph.rankdir of + TB -> + List.reverse + + _ -> + identity + + subgraphs = + [ -- pipeline (stages, steps) subgraph and cluster + pipelineSubgraph + , "" + + -- built-in (init, clone) subgraph and cluster + , builtInSubgraph + , "" + + -- services subgraph and cluster + , serviceSubgraph + ] + in + digraph (baseGraphStyles model.repo.build.graph.rankdir) + (rotation subgraphs) + + +{-| nodeLabel : takes model, graph info, a node, and returns a string representation of the "label" applied to a node element. +a "label" is actually a disguised graphviz table that is used to +render a list of stage-steps as graph content that is recognized by the layout +-} +nodeLabel : BuildModel.PartialModel a -> Repository -> Build -> BuildGraphNode -> Bool -> String +nodeLabel model repo build node showSteps = + let + label = + node.name + + steps = + List.sortBy .id node.steps + + table content = + "" + ++ String.concat content + ++ "
" + + runtime = + Util.formatRunTime model.time node.startedAt node.finishedAt + + header = + "" + ++ "" + ++ ("" + ++ "" + ++ label + ++ "" + ++ "" + ++ (if node.cluster /= serviceClusterID then + " (" + ++ (String.fromInt <| List.length steps) + ++ ")" + + else + "" + ) + ) + ++ "" + ++ "" + ++ runtime + ++ "" + ++ "" + + link step = + Routes.routeToUrl <| + Routes.Build repo.org + repo.name + (String.fromInt build.number) + (Just <| + Focus.resourceFocusFragment + "step" + (String.fromInt step.number) + [] + ) + + -- table row and cell styling + rowAttributes _ = + [-- row attributes go here + ] + + cellAttributes step = + [ ( "border", DefaultEscape "0" ) + , ( "cellborder", DefaultEscape "0" ) + , ( "cellspacing", DefaultEscape "0" ) + , ( "margin", DefaultEscape "0" ) + , ( "align", DefaultEscape "left" ) + , ( "href", DefaultEscape <| link step ) + , ( "id", DefaultEscape "node-cell" ) + , ( "title", DefaultEscape ("#status-" ++ statusToString step.status) ) + , ( "tooltip", DefaultEscape (String.join "," [ String.fromInt step.id, step.name, statusToString step.status ]) ) + ] + + row step = + "" + ++ "" + -- required icon spacing + ++ " " + ++ "" + ++ step.name + ++ "" + ++ "
" + ++ "" + ++ "" + + rows = + if showSteps then + List.map row steps + + else + [] + in + table <| header :: rows + + +{-| nodeLabel : takes model and a node, and returns the DOT string representation +-} +nodeToString : BuildModel.PartialModel a -> Repository -> Build -> Node BuildGraphNode -> String +nodeToString model repo build node = + " " + ++ String.fromInt node.id + ++ makeAttributes (nodeAttributes model repo build node.label) + + +{-| edgeToString : takes model and a node, and returns the DOT string representation +-} +edgeToString : Edge BuildGraphEdge -> String +edgeToString edge = + " " + ++ String.fromInt edge.from + ++ " -> " + ++ String.fromInt edge.to + ++ makeAttributes (edgeAttributes edge.label) + + + +-- STYLES + + +{-| baseGraphStyles : returns the base styles applied to the root graph. +-} +baseGraphStyles : Rankdir -> Styles +baseGraphStyles rankdir = + { rankdir = rankdir + , graph = + escapeAttributes + [ ( "bgcolor", DefaultEscape "transparent" ) + , ( "splines", DefaultEscape "ortho" ) + ] + , node = + escapeAttributes + [ ( "color", DefaultEscape "#151515" ) + , ( "style", DefaultEscape "filled" ) + , ( "fontname", DefaultEscape "Arial" ) + ] + , edge = + escapeAttributes + [ ( "color", DefaultEscape "azure2" ) + , ( "penwidth", DefaultEscape "1" ) + , ( "arrowhead", DefaultEscape "dot" ) + , ( "arrowsize", DefaultEscape "0.5" ) + , ( "minlen", DefaultEscape "1" ) + ] + } + + +{-| builtInSubgraphStyles : returns the styles applied to the built-in-steps subgraph. +-} +builtInSubgraphStyles : Styles +builtInSubgraphStyles = + { rankdir = LR -- unused with subgraph but required by model + , graph = + escapeAttributes + [ ( "bgcolor", DefaultEscape "transparent" ) + , ( "peripheries", DefaultEscape "0" ) + ] + , node = + escapeAttributes + [ ( "color", DefaultEscape "#151515" ) + , ( "style", DefaultEscape "filled" ) + , ( "fontname", DefaultEscape "Arial" ) + ] + , edge = + escapeAttributes + [ ( "minlen", DefaultEscape "1" ) + ] + } + + +{-| pipelineSubgraphStyles : returns the styles applied to the pipeline-steps subgraph. +-} +pipelineSubgraphStyles : Styles +pipelineSubgraphStyles = + { rankdir = LR -- unused with subgraph but required by model + , graph = + escapeAttributes + [ ( "bgcolor", DefaultEscape "transparent" ) + , ( "peripheries", DefaultEscape "0" ) + ] + , node = + escapeAttributes + [ ( "color", DefaultEscape "#151515" ) + , ( "style", DefaultEscape "filled" ) + , ( "fontname", DefaultEscape "Arial" ) + ] + , edge = + escapeAttributes + [ ( "color", DefaultEscape "azure2" ) + , ( "penwidth", DefaultEscape "2" ) + , ( "arrowhead", DefaultEscape "dot" ) + , ( "arrowsize", DefaultEscape "0.5" ) + , ( "minlen", DefaultEscape "2" ) + ] + } + + +{-| serviceSubgraphStyles : returns the styles applied to the services subgraph. +-} +serviceSubgraphStyles : Styles +serviceSubgraphStyles = + { rankdir = LR -- unused with subgraph but required by model + , graph = + escapeAttributes + [ ( "bgcolor", DefaultEscape "transparent" ) + , ( "peripheries", DefaultEscape "0" ) + ] + , node = + escapeAttributes + [ ( "color", DefaultEscape "#151515" ) + , ( "style", DefaultEscape "filled" ) + , ( "fontname", DefaultEscape "Arial" ) + ] + , edge = + escapeAttributes + [ ( "color", DefaultEscape "azure2" ) + , ( "penwidth", DefaultEscape "0" ) + , ( "arrowhead", DefaultEscape "dot" ) + , ( "arrowsize", DefaultEscape "0" ) + , ( "minlen", DefaultEscape "1" ) + , ( "style", DefaultEscape "invis" ) + ] + } + + +{-| nodeLabelTableAttributes : returns the base styles applied to all node label-tables +-} +nodeLabelTableAttributes : List ( String, AttributeValue ) +nodeLabelTableAttributes = + [ ( "border", DefaultEscape "0" ) + , ( "cellborder", DefaultEscape "0" ) + , ( "cellspacing", DefaultEscape "5" ) + , ( "margin", DefaultEscape "0" ) + ] + + +{-| nodeAttributes : returns the node-specific dynamic attributes +-} +nodeAttributes : BuildModel.PartialModel a -> Repository -> Build -> BuildGraphNode -> Dict String Attribute +nodeAttributes model repo build node = + let + -- embed node information in the element id + id = + "#" + ++ String.join "," + [ String.fromInt node.id + , node.name + , node.status + , Util.boolToString node.focused + ] + + class = + String.join " " + [ "elm-build-graph-node" -- generic styling + , "elm-build-graph-node-" ++ String.fromInt node.id -- selector used for testing + ] + + -- track step expansion using the model and OnGraphInteraction + showSteps = + model.repo.build.graph.showSteps + in + Dict.fromList <| + -- node attributes + [ ( "shape", DefaultJSONLabelEscape "rect" ) + , ( "style", DefaultJSONLabelEscape "filled" ) + , ( "border", DefaultJSONLabelEscape "white" ) + + -- dynamic attributes + , ( "id", DefaultJSONLabelEscape id ) + , ( "class", DefaultJSONLabelEscape class ) + , ( "href", DefaultJSONLabelEscape ("#" ++ node.name) ) + , ( "label", HtmlLabelEscape <| nodeLabel model repo build node showSteps ) + , ( "tooltip", DefaultJSONLabelEscape id ) + ] + + +{-| edgeAttributes : returns the edge-specific dynamic attributes +-} +edgeAttributes : BuildGraphEdge -> Dict String Attribute +edgeAttributes edge = + let + -- embed edge information in the element id to use during OnGraphInteraction callbacks + id = + "#" + ++ String.join "," + [ String.fromInt edge.source + , String.fromInt edge.destination + , edge.status + , Util.boolToString edge.focused + ] + + class = + String.join " " + [ "elm-build-graph-edge" -- generic styling + + -- selector used for testing + , "elm-build-graph-edge-" + ++ String.fromInt edge.source + ++ "-" + ++ String.fromInt edge.destination + ] + in + Dict.fromList <| + [ ( "id", DefaultJSONLabelEscape id ) + , ( "class", DefaultJSONLabelEscape class ) + , ( "style", DefaultJSONLabelEscape "filled" ) + ] + + +{-| builtInClusterID : constant for organizing the layout of build graph nodes +-} +builtInClusterID : Int +builtInClusterID = + 2 + + +{-| pipelineClusterID : constant for organizing the layout of build graph nodes +-} +pipelineClusterID : Int +pipelineClusterID = + 1 + + +{-| serviceClusterID : constant for organizing the layout of build graph nodes +-} +serviceClusterID : Int +serviceClusterID = + 0 diff --git a/src/elm/Pages/Build/Graph/Interop.elm b/src/elm/Pages/Build/Graph/Interop.elm new file mode 100644 index 000000000..af3d256b2 --- /dev/null +++ b/src/elm/Pages/Build/Graph/Interop.elm @@ -0,0 +1,35 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Build.Graph.Interop exposing (renderBuildGraph) + +import Interop +import Pages.Build.Graph.DOT exposing (renderDOT) +import Pages.Build.Model as BuildModel +import RemoteData exposing (RemoteData(..)) +import Routes exposing (Route(..)) +import Vela exposing (encodeBuildGraphRenderData) + + +{-| renderBuildGraph : takes partial build model and render options, and returns a cmd for dispatching a graphviz+d3 render command +-} +renderBuildGraph : BuildModel.PartialModel a -> Bool -> Cmd msg +renderBuildGraph model centerOnDraw = + -- rendering the full graph requires repo, build and graph + case ( model.repo.repo, model.repo.build.build, model.repo.build.graph.graph ) of + ( Success r, Success b, Success g ) -> + Interop.renderBuildGraph <| + encodeBuildGraphRenderData + { dot = renderDOT model r b g + , buildID = b.id + , filter = model.repo.build.graph.filter + , showServices = model.repo.build.graph.showServices + , showSteps = model.repo.build.graph.showSteps + , focusedNode = model.repo.build.graph.focusedNode + , centerOnDraw = centerOnDraw + } + + _ -> + Cmd.none diff --git a/src/elm/Pages/Build/Graph/View.elm b/src/elm/Pages/Build/Graph/View.elm new file mode 100644 index 000000000..d475c94a6 --- /dev/null +++ b/src/elm/Pages/Build/Graph/View.elm @@ -0,0 +1,200 @@ +{-- +SPDX-License-Identifier: Apache-2.0 +--} + + +module Pages.Build.Graph.View exposing (view) + +import FeatherIcons +import Html exposing (Html, button, div, li, text, ul) +import Html.Attributes exposing (class, id) +import Html.Events exposing (onCheck, onClick) +import Pages.Build.Model exposing (Msgs, PartialModel) +import RemoteData exposing (RemoteData(..)) +import Routes exposing (Route(..)) +import Svg +import Svg.Attributes +import SvgBuilder exposing (buildVizLegendEdge, buildVizLegendNode) +import Util +import Vela exposing (BuildNumber, Org, Repo) +import Visualization.DOT as DOT exposing (Attribute(..), AttributeValue(..)) + + + +-- VIEW + + +{-| view : renders the elm build graph root. the graph root is selected by d3 and filled with graphviz content. +-} +view : PartialModel a -> Msgs msg -> Org -> Repo -> BuildNumber -> Html msg +view model msgs org repo buildNumber = + div [ class "elm-build-graph-container" ] + [ div [ class "elm-build-graph-actions" ] + [ ul [] + [ li [] + [ button [ class "button", class "-icon", id "action-center", Html.Attributes.title "Recenter visualization" ] + [ FeatherIcons.minimize + |> FeatherIcons.withSize 20 + |> FeatherIcons.withClass "elm-build-graph-action-button" + |> FeatherIcons.toHtml [] + ] + ] + , li [] + [ button + [ class "button" + , class "-icon" + , class "build-graph-action-refresh" + , Html.Attributes.title "Refresh visualization" + , onClick <| msgs.buildGraphMsgs.refresh org repo buildNumber + ] + [ FeatherIcons.refreshCw + |> FeatherIcons.withSize 20 + |> FeatherIcons.withClass "elm-build-graph-action-button" + |> FeatherIcons.toHtml [] + ] + ] + , li [] + [ button + [ class "button" + , class "-icon" + , class "build-graph-action-rotate" + , class <| + case model.repo.build.graph.rankdir of + DOT.TB -> + "-vertical" + + _ -> + "" + , Html.Attributes.title "Rotate visualization" + , onClick <| msgs.buildGraphMsgs.rotate + ] + [ FeatherIcons.share2 + |> FeatherIcons.withSize 20 + |> FeatherIcons.withClass "elm-build-graph-action-button" + |> FeatherIcons.toHtml [] + ] + ] + ] + , div [ class "elm-build-graph-action-toggles" ] + [ div [ class "form-control" ] + [ div [] + [ Html.input + [ Html.Attributes.type_ "checkbox" + , Html.Attributes.checked model.repo.build.graph.showServices + , onCheck msgs.buildGraphMsgs.showServices + , id "checkbox-services-toggle" + , Util.testAttribute "build-graph-action-toggle-services" + ] + [] + , Html.label [ class "form-label", Html.Attributes.for "checkbox-services-toggle" ] + [ text "services" + ] + ] + ] + , div [ class "form-control" ] + [ div [] + [ Html.input + [ Html.Attributes.type_ "checkbox" + , Html.Attributes.checked model.repo.build.graph.showSteps + , onCheck msgs.buildGraphMsgs.showSteps + , id "checkbox-steps-toggle" + , Util.testAttribute "build-graph-action-toggle-steps" + ] + [] + , Html.label [ class "form-label", Html.Attributes.for "checkbox-steps-toggle" ] + [ text "steps" + ] + ] + ] + , div [ class "form-control", class "elm-build-graph-search-filter" ] + [ div [ class "elm-build-graph-search-filter-input" ] + [ Html.input + [ Html.Attributes.type_ "input" + , Html.Attributes.placeholder "type to highlight nodes..." + , Html.Events.onInput msgs.buildGraphMsgs.updateFilter + , id "build-graph-action-filter" + , Util.testAttribute "build-graph-action-filter" + , Html.Attributes.value model.repo.build.graph.filter + ] + [] + , Html.label [ class "elm-build-graph-search-filter-form-label", Html.Attributes.for "build-graph-action-filter" ] + [ FeatherIcons.search + |> FeatherIcons.withSize 20 + |> FeatherIcons.withClass "elm-build-graph-action-button" + |> FeatherIcons.toHtml [] + ] + ] + , button + [ class "button" + , class "-icon" + , Util.testAttribute "build-graph-action-filter-clear" + , onClick (msgs.buildGraphMsgs.updateFilter "") + ] + [ FeatherIcons.x + |> FeatherIcons.withSize 20 + |> FeatherIcons.withClass "elm-build-graph-action-button" + |> FeatherIcons.toHtml [] + ] + ] + ] + ] + , div [ class "elm-build-graph-window" ] + [ ul [ class "elm-build-graph-legend" ] + [ li [] + [ buildVizLegendNode [ Svg.Attributes.class "-pending" ] + , text "pending" + ] + , li [ class "-running-hover" ] + [ buildVizLegendNode [ Svg.Attributes.class "-running" ] + , text "running" + ] + , li [] + [ buildVizLegendNode [ Svg.Attributes.class "-success" ] + , text "success" + ] + , li [] + [ buildVizLegendNode [ Svg.Attributes.class "-failure" ] + , text "failed" + ] + , li [] + [ buildVizLegendNode [ Svg.Attributes.class "-canceled" ] + , text "canceled" + ] + , li [] + [ buildVizLegendNode [ Svg.Attributes.class "-killed" ] + , text "skipped" + ] + , li [] + [ buildVizLegendNode [ Svg.Attributes.class "-selected" ] + , text "selected" + ] + , li [] + [ buildVizLegendEdge [ Svg.Attributes.class "-pending" ] + , text "pending" + ] + , li [] + [ buildVizLegendEdge [ Svg.Attributes.class "-finished" ] + , text "complete" + ] + ] + , case model.repo.build.graph.graph of + RemoteData.Success _ -> + -- dont render anything when the build graph draw command has been dispatched + text "" + + RemoteData.Failure _ -> + div [ class "elm-build-graph-error" ] + [ text "Unable to load build graph, please refresh or try again later!" + ] + + RemoteData.Loading -> + Util.largeLoader + + RemoteData.NotAsked -> + Util.largeLoader + , Svg.svg + [ Svg.Attributes.class "elm-build-graph-root" + ] + [] + ] + ] diff --git a/src/elm/Pages/Build/History.elm b/src/elm/Pages/Build/History.elm index 5bf9d4a1b..9b2b6c0c2 100644 --- a/src/elm/Pages/Build/History.elm +++ b/src/elm/Pages/Build/History.elm @@ -40,6 +40,9 @@ view now timezone page limit rm = Pages.BuildPipeline _ _ b _ _ -> Maybe.withDefault -1 <| String.toInt b + Pages.BuildGraph _ _ b -> + Maybe.withDefault -1 <| String.toInt b + _ -> -1 in @@ -111,6 +114,9 @@ recentBuildLink page org repo buildNumber build idx = Pages.BuildPipeline _ _ _ _ _ -> Routes.href <| Routes.BuildPipeline org repo (String.fromInt build.number) Nothing Nothing + Pages.BuildGraph _ _ _ -> + Routes.href <| Routes.BuildGraph org repo (String.fromInt build.number) + _ -> Routes.href <| Routes.Build org repo (String.fromInt build.number) Nothing , attribute "aria-label" <| "go to previous build number " ++ String.fromInt build.number diff --git a/src/elm/Pages/Build/Model.elm b/src/elm/Pages/Build/Model.elm index 1644eadc5..f45189168 100644 --- a/src/elm/Pages/Build/Model.elm +++ b/src/elm/Pages/Build/Model.elm @@ -61,6 +61,7 @@ type alias Msgs msg = , restartBuild : RestartBuild msg , cancelBuild : CancelBuild msg , toggle : Maybe Int -> Maybe Bool -> msg + , buildGraphMsgs : BuildGraphMsgs msg } @@ -73,6 +74,15 @@ type alias LogsMsgs msg = } +type alias BuildGraphMsgs msg = + { refresh : Org -> Repo -> BuildNumber -> msg + , rotate : msg + , showServices : Bool -> msg + , showSteps : Bool -> msg + , updateFilter : String -> msg + } + + type alias RestartBuild msg = Org -> Repo -> BuildNumber -> msg diff --git a/src/elm/Pages/Build/View.elm b/src/elm/Pages/Build/View.elm index d23e974e7..be6b02032 100644 --- a/src/elm/Pages/Build/View.elm +++ b/src/elm/Pages/Build/View.elm @@ -5,6 +5,7 @@ SPDX-License-Identifier: Apache-2.0 module Pages.Build.View exposing ( viewBuild + , viewBuildGraph , viewBuildServices , viewPreview , wrapWithBuildPreview @@ -38,6 +39,7 @@ import Html.Attributes import Html.Events exposing (onClick) import List.Extra exposing (unique) import Nav exposing (viewBuildTabs) +import Pages.Build.Graph.View import Pages.Build.Logs exposing ( bottomTrackerFocusId @@ -1166,6 +1168,18 @@ viewError build = +-- VISUALIZE + + +{-| viewBuildGraph : renders build graph using graphviz and d3 +-} +viewBuildGraph : PartialModel a -> Msgs msg -> Org -> Repo -> BuildNumber -> Html msg +viewBuildGraph model msgs org repo buildNumber = + wrapWithBuildPreview model msgs org repo buildNumber <| + Pages.Build.Graph.View.view model msgs org repo buildNumber + + + -- HELPERS diff --git a/src/elm/Routes.elm b/src/elm/Routes.elm index 718cabba1..8c1662504 100644 --- a/src/elm/Routes.elm +++ b/src/elm/Routes.elm @@ -45,6 +45,7 @@ type Route | Build Org Repo BuildNumber FocusFragment | BuildServices Org Repo BuildNumber FocusFragment | BuildPipeline Org Repo BuildNumber (Maybe ExpandTemplatesQuery) FocusFragment + | BuildGraph Org Repo BuildNumber | AddSchedule Org Repo | Schedules Org Repo (Maybe Pagination.Page) (Maybe Pagination.PerPage) | Schedule Org Repo ScheduleName @@ -93,6 +94,7 @@ routes = , map Build (string string string fragment identity) , map BuildServices (string string string s "services" fragment identity) , map BuildPipeline (string string string s "pipeline" Query.string "expand" fragment identity) + , map BuildGraph (string string string s "graph") , map NotFound (s "404") ] @@ -203,6 +205,9 @@ routeToUrl route = BuildPipeline org repo buildNumber expand lineFocus -> "/" ++ org ++ "/" ++ repo ++ "/" ++ buildNumber ++ "/pipeline" ++ (UB.toQuery <| List.filterMap identity <| [ maybeToQueryParam expand "expand" ]) ++ Maybe.withDefault "" lineFocus + BuildGraph org repo buildNumber -> + "/" ++ org ++ "/" ++ repo ++ "/" ++ buildNumber ++ "/graph" + Authenticate { code, state } -> "/account/authenticate" ++ paramsToQueryString { code = code, state = state } diff --git a/src/elm/SvgBuilder.elm b/src/elm/SvgBuilder.elm index 53d8aa8ad..0c5b35dec 100644 --- a/src/elm/SvgBuilder.elm +++ b/src/elm/SvgBuilder.elm @@ -6,6 +6,8 @@ SPDX-License-Identifier: Apache-2.0 module SvgBuilder exposing ( buildStatusAnimation , buildStatusToIcon + , buildVizLegendEdge + , buildVizLegendNode , hookStatusToIcon , hookSuccess , recentBuildStatusToIcon @@ -24,14 +26,17 @@ import Svg.Attributes , cx , cy , d + , fill , height , r , strokeLinecap , strokeWidth , viewBox , width + , x , x1 , x2 + , y , y1 , y2 ) @@ -714,3 +719,64 @@ terminal = [ Svg.polyline [ Svg.Attributes.points "4 17 10 11 4 5" ] [] , Svg.line [ x1 "12", y1 "19", x2 "20", y2 "19" ] [] ] + + +{-| buildVizLegendNode : produces svg for a build graph legend node +-} +buildVizLegendNode : List (Svg.Attribute msg) -> Html msg +buildVizLegendNode attrs = + let + size = + 22 + + padding = + 4 + in + svg + [ class "elm-build-graph-legend-node" + , width <| String.fromInt size + , height <| String.fromInt size + ] + [ Svg.rect + ([ width <| String.fromInt (size - padding) + , height <| String.fromInt (size - padding) + , x <| String.fromInt (padding // 2) + , y <| String.fromInt (padding // 2) + ] + ++ attrs + ) + [] + ] + + +{-| buildVizLegendEdge : produces line svg for a build graph legend edge +-} +buildVizLegendEdge : List (Svg.Attribute msg) -> Html msg +buildVizLegendEdge attrs = + let + size = + 22 + + padding = + 4 + + length = + 22 + in + svg + [ width <| String.fromInt size + , height <| String.fromInt size + , class "elm-build-graph-legend-edge" + ] + [ Svg.line + ([ x1 <| String.fromInt 0 + , x2 <| String.fromInt length + , y1 <| String.fromInt (size // 2) + , y2 <| String.fromInt (size // 2) + , width <| String.fromInt (size - padding) + , height <| String.fromInt (size - padding) + ] + ++ attrs + ) + [] + ] diff --git a/src/elm/Util.elm b/src/elm/Util.elm index 262847bf5..d1102c6ca 100644 --- a/src/elm/Util.elm +++ b/src/elm/Util.elm @@ -8,6 +8,7 @@ module Util exposing , ariaHidden , attrIf , base64Decode + , boolToString , boolToYesNo , buildRefURL , checkScheduleAllowlist @@ -383,6 +384,17 @@ attrIf cond attr = class "" +{-| boolToString : takes bool and converts to true/false string +-} +boolToString : Bool -> String +boolToString bool = + if bool then + "true" + + else + "false" + + {-| boolToYesNo : takes bool and converts to yes/no string -} boolToYesNo : Bool -> String diff --git a/src/elm/Vela.elm b/src/elm/Vela.elm index 222b1e5fc..938e285b3 100644 --- a/src/elm/Vela.elm +++ b/src/elm/Vela.elm @@ -7,6 +7,10 @@ module Vela exposing ( AddSchedulePayload , AuthParams , Build + , BuildGraph + , BuildGraphEdge + , BuildGraphModel + , BuildGraphNode , BuildModel , BuildNumber , Builds @@ -30,6 +34,7 @@ module Vela exposing , Favorites , Field , FocusFragment + , GraphInteraction , Hook , HookNumber , Hooks @@ -87,10 +92,12 @@ module Vela exposing , buildUpdateSchedulePayload , buildUpdateSecretPayload , decodeBuild + , decodeBuildGraph , decodeBuilds , decodeCurrentUser , decodeDeployment , decodeDeployments + , decodeGraphInteraction , decodeHooks , decodeLog , decodePipelineConfig @@ -106,12 +113,14 @@ module Vela exposing , decodeSourceRepositories , decodeStep , decodeTheme + , defaultBuildGraph , defaultEnableRepositoryPayload , defaultFavicon , defaultPipeline , defaultPipelineTemplates , defaultRepoModel , defaultStep + , encodeBuildGraphRenderData , encodeDeploymentPayload , encodeEnableRepository , encodeTheme @@ -124,8 +133,14 @@ module Vela exposing , secretTypeToString , secretsErrorLabel , statusToFavicon + , statusToString + , stringToStatus , stringToTheme , updateBuild + , updateBuildGraph + , updateBuildGraphFilter + , updateBuildGraphShowServices + , updateBuildGraphShowSteps , updateBuildNumber , updateBuildPipelineConfig , updateBuildPipelineExpand @@ -163,6 +178,7 @@ module Vela exposing , updateRepoEnabling , updateRepoInitialized , updateRepoLimit + , updateRepoModels , updateRepoTimeout ) @@ -171,11 +187,13 @@ import Bytes.Encode import Dict exposing (Dict) import Errors exposing (Error) import Json.Decode as Decode exposing (Decoder, andThen, bool, int, string, succeed) +import Json.Decode.Extra exposing (dict2) import Json.Decode.Pipeline exposing (hardcoded, optional, required) import Json.Encode as Encode exposing (Value) import LinkHeader exposing (WebLink) import RemoteData exposing (RemoteData(..), WebData) import Url.Builder as UB +import Visualization.DOT as DOT @@ -401,6 +419,7 @@ type alias BuildModel = , build : WebData Build , steps : StepsModel , services : ServicesModel + , graph : BuildGraphModel } @@ -420,9 +439,23 @@ type alias ServicesModel = } +updateRepoModels : { a | repo : RepoModel } -> RepoModel -> BuildModel -> BuildGraphModel -> { a | repo : RepoModel } +updateRepoModels m rm bm gm = + { m + | repo = + { rm + | build = + { bm + | graph = + gm + } + } + } + + defaultBuildModel : BuildModel defaultBuildModel = - BuildModel "" NotAsked defaultStepsModel defaultServicesModel + BuildModel "" NotAsked defaultStepsModel defaultServicesModel defaultBuildGraphModel defaultRepoModel : RepoModel @@ -712,6 +745,54 @@ updateBuildSteps update rm = { rm | build = { b | steps = { s | steps = update } } } +updateBuildGraph : WebData BuildGraph -> RepoModel -> RepoModel +updateBuildGraph update rm = + let + b = + rm.build + + g = + b.graph + in + { rm | build = { b | graph = { g | graph = update } } } + + +updateBuildGraphShowServices : Bool -> RepoModel -> RepoModel +updateBuildGraphShowServices update rm = + let + b = + rm.build + + g = + b.graph + in + { rm | build = { b | graph = { g | showServices = update } } } + + +updateBuildGraphShowSteps : Bool -> RepoModel -> RepoModel +updateBuildGraphShowSteps update rm = + let + b = + rm.build + + g = + b.graph + in + { rm | build = { b | graph = { g | showSteps = update } } } + + +updateBuildGraphFilter : String -> RepoModel -> RepoModel +updateBuildGraphFilter update rm = + let + b = + rm.build + + g = + b.graph + in + { rm | build = { b | graph = { g | filter = update } } } + + updateBuildServices : WebData Services -> RepoModel -> RepoModel updateBuildServices update rm = let @@ -1298,6 +1379,125 @@ decodeBuild = |> optional "deploy_payload" decodeDeploymentParameters Nothing +defaultBuildGraphModel : BuildGraphModel +defaultBuildGraphModel = + BuildGraphModel "" NotAsked DOT.LR "" -1 True True + + +defaultBuildGraph : BuildGraph +defaultBuildGraph = + BuildGraph Dict.empty [] + + +encodeBuildGraphRenderData : BuildGraphRenderInteropData -> Encode.Value +encodeBuildGraphRenderData graphData = + Encode.object + [ ( "dot", Encode.string graphData.dot ) + , ( "buildID", Encode.int graphData.buildID ) + , ( "filter", Encode.string graphData.filter ) + , ( "focusedNode", Encode.int graphData.focusedNode ) + , ( "showServices", Encode.bool graphData.showServices ) + , ( "showSteps", Encode.bool graphData.showSteps ) + , ( "centerOnDraw", Encode.bool graphData.centerOnDraw ) + ] + + +type alias BuildGraphRenderInteropData = + { dot : String + , buildID : Int + , filter : String + , focusedNode : Int + , showServices : Bool + , showSteps : Bool + , centerOnDraw : Bool + } + + +type alias BuildGraphModel = + { buildNumber : BuildNumber + , graph : WebData BuildGraph + , rankdir : DOT.Rankdir + , filter : String + , focusedNode : Int + , showServices : Bool + , showSteps : Bool + } + + +type alias BuildGraph = + { nodes : Dict Int BuildGraphNode + , edges : List BuildGraphEdge + } + + +type alias BuildGraphNode = + { cluster : Int + , id : Int + , name : String + , status : String + , startedAt : Int + , finishedAt : Int + , steps : List Step + , focused : Bool + } + + +type alias BuildGraphEdge = + { cluster : Int + , source : Int + , destination : Int + , status : String + , focused : Bool + } + + +decodeBuildGraph : Decoder BuildGraph +decodeBuildGraph = + Decode.succeed BuildGraph + |> required "nodes" (dict2 int decodeBuildGraphNode) + |> optional "edges" (Decode.list decodeEdge) [] + + +decodeBuildGraphNode : Decoder BuildGraphNode +decodeBuildGraphNode = + Decode.succeed BuildGraphNode + |> required "cluster" int + |> required "id" int + |> required "name" Decode.string + |> optional "status" string "" + |> required "started_at" int + |> required "finished_at" int + |> optional "steps" (Decode.list decodeStep) [] + -- focused + |> hardcoded False + + +decodeEdge : Decoder BuildGraphEdge +decodeEdge = + Decode.succeed BuildGraphEdge + |> required "cluster" int + |> required "source" int + |> required "destination" int + |> optional "status" string "" + -- focused + |> hardcoded False + + +type alias GraphInteraction = + { eventType : String + , href : String + , nodeID : String + } + + +decodeGraphInteraction : Decoder GraphInteraction +decodeGraphInteraction = + Decode.succeed GraphInteraction + |> required "eventType" string + |> optional "href" string "" + |> optional "nodeID" string "-1" + + {-| decodeBuilds : decodes json from vela into list of builds -} decodeBuilds : Decoder Builds @@ -1368,6 +1568,63 @@ toStatus status = succeed Error +{-| stringToStatus : helper to convert string to Status +-} +stringToStatus : String -> Status +stringToStatus status = + case status of + "pending" -> + Pending + + "running" -> + Running + + "success" -> + Success + + "failure" -> + Failure + + "killed" -> + Killed + + "canceled" -> + Canceled + + "error" -> + Error + + _ -> + Error + + +{-| statusToString : helper to convert Status to string +-} +statusToString : Status -> String +statusToString status = + case status of + Pending -> + "pending" + + Running -> + "running" + + Success -> + "success" + + Failure -> + "failure" + + Killed -> + "killed" + + Canceled -> + "canceled" + + Error -> + "error" + + {-| isComplete : helper to determine if status is 'complete' -} isComplete : Status -> Bool diff --git a/src/elm/Visualization/DOT.elm b/src/elm/Visualization/DOT.elm new file mode 100644 index 000000000..ff2a78b62 --- /dev/null +++ b/src/elm/Visualization/DOT.elm @@ -0,0 +1,176 @@ +module Visualization.DOT exposing + ( Attribute(..) + , AttributeValue(..) + , Rankdir(..) + , Styles + , clusterSubgraph + , digraph + , escapeAttributes + , makeAttributes + ) + +import Dict exposing (Dict) +import Json.Encode + + + +-- TYPES + + +{-| Styles : graph style options +-} +type alias Styles = + { rankdir : Rankdir + , graph : String + , node : String + , edge : String + } + + +{-| Rankdir : direction of the graph render layout +-} +type Rankdir + = TB + | LR + | BT + | RL + + +{-| Attribute : used for escaping graph layout attribute keys +-} +type Attribute + = DefaultJSONLabelEscape String + | HtmlLabelEscape String + + +{-| AttributeValue : used for escaping graph layout attribute values +-} +type AttributeValue + = DefaultEscape String + | BooleanEscape String + + + +-- DOT HELPERS + + +{-| digraph : takes styles and graph content and wraps it in a DOT directed graph layout to be +used with graphviz/DOT: +-} +digraph : Styles -> List String -> String +digraph styles content = + String.join "\n" <| + List.concat + [ [ "digraph G {" -- start graph + , " compound=true" -- adds support for subgraph edges + , " rankdir=" ++ rankDirToString styles.rankdir + , " graph [" ++ styles.graph ++ "]" + , " node [" ++ styles.node ++ "]" + , " edge [" ++ styles.edge ++ "]" + , "" + ] + , content + , [ "" + , "}" -- end graph + , "" + ] + ] + + +{-| clusterSubgraph : takes cluster ID, styles, nodes and edges, and wraps it in a DOT directed subgraph layout to be +used with graphviz/DOT: +-} +clusterSubgraph : Int -> Styles -> String -> String -> String +clusterSubgraph cluster styles nodesString edgesString = + String.join "\n" + [ "subgraph cluster_" ++ String.fromInt cluster ++ " {" -- start subgraph + , " graph [" ++ styles.graph ++ "]" + , " node [" ++ styles.node ++ "]" + , " edge [" ++ styles.edge ++ "]" + , "" + , edgesString + , "" + , nodesString + , "" + , "}" -- end subgraph + ] + + +{-| rankDirToString : takes Rankdir type and returns it as a string +-} +rankDirToString : Rankdir -> String +rankDirToString r = + case r of + TB -> + "TB" + + LR -> + "LR" + + BT -> + "BT" + + RL -> + "RL" + + +{-| escapeCharacters : takes string and escapes special characters to prepare for use in a DOT string +-} +escapeCharacters : String -> String +escapeCharacters s = + s + |> String.replace "&" "&" + |> String.replace "<" "<" + |> String.replace ">" ">" + |> String.replace "\"" """ + |> String.replace "'" "'" + + +{-| escapeAttributes : takes list of string attributes and escapes special characters for keys and values to prepare for use in a DOT string +-} +escapeAttributes : List ( String, AttributeValue ) -> String +escapeAttributes attributes = + List.map + (\( k, attributeValue ) -> + case attributeValue of + DefaultEscape v -> + escapeCharacters k ++ "=\"" ++ escapeCharacters v ++ "\"" + + BooleanEscape v -> + escapeCharacters k ++ "=" ++ v ++ "" + ) + attributes + |> String.join " " + + +{-| attributeToString : takes attribute and returns it as a string +-} +attributeToString : Attribute -> String +attributeToString attribute = + case attribute of + DefaultJSONLabelEscape s -> + Json.Encode.string s + |> Json.Encode.encode 0 + + HtmlLabelEscape h -> + "<" ++ h ++ ">" + + +{-| makeAttributes : takes dictionary of attributes and returns them as a string +-} +makeAttributes : Dict String Attribute -> String +makeAttributes d = + if Dict.isEmpty d then + "" + + else + " [" ++ attributeKeyValuePairs d ++ "]" + + +{-| attributeKeyValuePairs : helper for taking dictionary of attributes and returns them as a string +-} +attributeKeyValuePairs : Dict String Attribute -> String +attributeKeyValuePairs = + Dict.toList + >> List.map (\( k, v ) -> k ++ "=" ++ attributeToString v) + >> String.join ", " diff --git a/src/scss/_animations.scss b/src/scss/_animations.scss index 5ea446a7d..dadf353a6 100644 --- a/src/scss/_animations.scss +++ b/src/scss/_animations.scss @@ -98,3 +98,8 @@ opacity: 0; } } +@keyframes dash { + to { + stroke-dashoffset: -1000; + } +} diff --git a/src/scss/_graph.scss b/src/scss/_graph.scss new file mode 100644 index 000000000..a3d136b68 --- /dev/null +++ b/src/scss/_graph.scss @@ -0,0 +1,414 @@ +// SPDX-License-Identifier: Apache-2.0 + +// start: classes controlled by Elm + +.elm-build-graph-actions { + position: relative; + + display: flex; + flex-direction: row; + justify-content: flex-start; + + border: 1px solid var(--color-bg-light); + border-bottom: 0; + + .elm-build-graph-action-toggles { + display: flex; + } + + .form-control { + div { + position: relative; + + margin: 1rem; + } + + border-left: 1px solid var(--color-bg-light); + } + + ul { + display: flex; + padding-left: 0.8rem; + + list-style: none; + } + + li { + display: flex; + margin-right: 1rem; + } + + button.-icon { + display: flex; + } +} + +.build-graph-action-refresh svg { + transform: rotate(0.2turn); +} + +.build-graph-action-rotate.-vertical svg { + transform: rotate(0.25turn); +} + +.elm-build-graph-window { + position: relative; + + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + min-height: 300px; + + background: var(--color-bg); + border: 1px solid var(--color-bg-light); + + .large-loader { + position: absolute; + top: 0; + right: 0; + z-index: 1; + + margin: 1rem; + } +} + +.elm-build-graph-error { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: 1rem; + + background: var(--color-bg-dark); + border-bottom: 1px solid var(--color-red); +} + +.elm-build-graph-legend { + position: absolute; + top: 0; + left: 0; + z-index: 1; + + font-size: 0.9rem; + + list-style: none; + + pointer-events: none; + + li { + margin-top: 0.4rem; + margin-left: -1rem; + + &.-running-hover { + animation: dash 100s linear; + animation-iteration-count: infinite; + } + } +} + +.elm-build-graph-legend-node { + margin-right: 0.2rem; + + fill: var(--color-bg-dark); + stroke: var(--color-gray); + + .-pending, + .-running { + stroke-dasharray: 7, 4; + } + + .-canceled, + .-failure { + stroke-dasharray: 3 3; + } + + .-running { + stroke: var(--color-yellow); + } + + .-success { + stroke: var(--color-green); + } + + .-failure { + stroke: var(--color-red); + } + + .-canceled { + stroke: var(--color-cyan-dark); + } + + .-killed, + .-skipped { + stroke: var(--color-lavender); + stroke-dasharray: 2, 4; + stroke-linecap: round; + } + + .-selected { + stroke: var(--color-primary); + stroke-width: 5; + } + + rect { + stroke-width: 2; + } +} + +.elm-build-graph-legend-edge { + margin-right: 0.2rem; + + fill: var(--color-bg-dark); + stroke: var(--color-gray); + + .-pending { + stroke-dasharray: 5, 3; + } + + .-running { + stroke: var(--color-yellow); + stroke-dasharray: 7, 4; + } + + .-success { + stroke: var(--color-green); + } + + .-failure { + stroke: var(--color-red); + } + + .-selected { + stroke: var(--color-primary); + } + + line { + stroke-width: 3; + } +} + +.elm-build-graph-search-filter { + padding-right: 1rem; + padding-left: 1rem; + + background-color: var(--color-bg); + border-right: 1px solid var(--color-bg-light); + + label { + padding-left: 0.5rem; + } +} + +.elm-build-graph-search-filter-input { + border-bottom: var(--line-width) solid var(--color-primary); + + input:not([type='checkbox']):not([type='radio']) { + border-bottom: none; + } +} + +// end: classes controlled by Elm + +// start: classes controlled by d3 + +/*! purgecss start ignore */ + +.d3-build-graph-node { + fill: var(--color-bg-dark); + stroke: var(--color-bg-dark); +} + +.d3-build-graph-node-a { + fill: var(--color-text); +} + +.d3-build-graph-node-outline-rect { + fill: none; + stroke-width: 1.8; + + &.-pending, + &.-running { + stroke-dasharray: 10; + } + + &.-failure, + &.-error, + &.-canceled { + stroke-dasharray: 4 3; + } + + &.-focus { + stroke-width: 3; + } + + &.-hover { + stroke-width: 4; + } + + &.-pending { + stroke: var(--color-gray); + } + + &.-running { + animation: dash 25s linear; + animation-iteration-count: infinite; + + stroke: var(--color-yellow); + } + + &.-success { + stroke: var(--color-green); + } + + &.-failure, + &.-error { + stroke: var(--color-red); + } + + &.-canceled { + stroke: var(--color-cyan-dark); + } + + &.-killed, + &.-skipped { + stroke-dasharray: 5; + stroke-linecap: round; + + stroke: var(--color-lavender); + } + + &.-focus, + &.-hover { + stroke: var(--color-primary); + } +} + +// icon background +.d3-build-graph-step-icon { + fill: none; + + &.-pending { + fill: var(--color-gray); + } + + &.-running { + fill: var(--color-yellow); + } + + &.-success { + fill: var(--color-green); + } + + &.-failure, + &.-error { + fill: var(--color-red); + } + + &.-canceled { + fill: var(--color-cyan-dark); + } + + &.-killed, + &.-skipped { + fill: var(--color-lavender); + } +} + +// icon content +.d3-build-graph-node-a svg { + stroke: var(--color-coal-dark); + stroke-width: 2; + + &.-error { + stroke-width: 3; + } + + &.-pending { + fill: var(--color-coal-dark); + } + + &.-running { + fill: var(--color-yellow); + } + + &.-success { + fill: var(--color-green); + } + + &.-failure, + &.-error { + fill: var(--color-red); + } + + &.-canceled { + fill: var(--color-cyan-dark); + } + + &.-killed, + &.-skipped { + fill: var(--color-coal-dark); + } +} + +.d3-build-graph-node-step-a { + &.-hover { + text { + fill: var(--color-primary); + } + } +} + +.d3-build-graph-node-step-a-underline { + &.-hover { + fill: var(--color-primary); + } +} + +.d3-build-graph-step-connector { + fill: var(--color-gray); +} + +.d3-build-graph-edge-path { + animation: none; + + &.-pending, + &.-running { + stroke-dasharray: 10, 4; + } + + &.-pending, + &.-success, + &.-failure, + &.-canceled, + &.-killed, + &.-skipped, + &.-error { + stroke-width: 1; + + stroke: var(--color-gray); + } + + &.-running { + animation: dash 25s linear; + animation-iteration-count: infinite; + + stroke: var(--color-yellow); + stroke-width: 2; + } + + &.-focus, + &.-hover { + stroke: var(--color-primary); + stroke-width: 2; + } +} + +.d3-build-graph-edge-tip { + fill: var(--color-gray); + stroke: var(--color-gray); +} + +// end: classes controlled by d3 + +/*! purgecss end ignore */ diff --git a/src/scss/_themes.scss b/src/scss/_themes.scss index 6d204ae07..28ac203c8 100644 --- a/src/scss/_themes.scss +++ b/src/scss/_themes.scss @@ -171,3 +171,79 @@ body.theme-light { color: var(--color-red-darkest); } } + +// build graph + +/*! purgecss start ignore */ + +.theme-light .elm-build-graph-legend-node { + stroke: var(--color-coal-light); +} + +.theme-light .d3-build-graph-edge-tip { + fill: var(--color-coal-light); + stroke: var(--color-coal-light); +} + +.theme-light .d3-build-graph-node-outline-rect { + &.-pending { + stroke: var(--color-coal-light); + } + + &.-running { + stroke: var(--color-yellow); + } + + &.-success { + stroke: var(--color-green); + } + + &.-failure, + &.-error { + stroke: var(--color-red); + } + + &.-canceled { + stroke: var(--color-cyan-dark); + } + + &.-killed, + &.-skipped { + stroke: var(--color-lavender); + } + + &.-focus, + &.-hover { + stroke: var(--color-primary); + } +} + +.theme-light .d3-build-graph-edge-path { + &.-pending, + &.-success, + &.-failure, + &.-canceled, + &.-killed, + &.-skipped, + &.-error { + stroke: var(--color-coal-light); + } + + &.-focus, + &.-hover { + stroke: var(--color-primary); + } +} + +// icon content +.theme-light .d3-build-graph-node-a svg { + stroke: var(--color-offwhite); + + &.-pending, + &.-killed, + &.-skipped { + fill: var(--color-offwhite); + } +} + +/*! purgecss end ignore */ diff --git a/src/scss/style.scss b/src/scss/style.scss index 00bf4c48c..1ccf0ca30 100644 --- a/src/scss/style.scss +++ b/src/scss/style.scss @@ -22,6 +22,7 @@ // pages @import 'settings'; @import 'pipelines'; +@import 'graph'; // general @import 'main'; diff --git a/src/static/graph.ts b/src/static/graph.ts new file mode 100644 index 000000000..09bcfbfc0 --- /dev/null +++ b/src/static/graph.ts @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: Apache-2.0 + +import * as d3 from 'd3'; + +export function drawGraph(opts, content) { + // force d3 exports to resolve, required or we receive errors when running within a container + // this is why we love javascript + var _ = d3; + + // define DOM selectors for DOT-generated elements + var graphSelectors = { + root: '.elm-build-graph-root', + node: '.elm-build-graph-node', + edge: '.elm-build-graph-edge', + }; + + var buildGraphElement = drawBaseGraphWithZoom( + opts, + graphSelectors.root, + content, + ); + + // check that a valid graph was rendered + if (buildGraphElement === null || buildGraphElement.node() === null) { + return; + } + + drawViewbox(opts, buildGraphElement); + + applyOnClickToNodes(opts, buildGraphElement, graphSelectors.node); + + var edges = drawEdges(opts, buildGraphElement, graphSelectors.edge); + + drawNodes(opts, buildGraphElement, graphSelectors.node, edges); +} + +function drawBaseGraphWithZoom(opts, selector, content) { + // grab the build graph root element + var buildGraphElement = d3.select(selector); + + var zoom = d3.zoom().scaleExtent([0.1, Infinity]).on('zoom', handleZoom); + + // define d3 zoom function + function handleZoom(event) { + if (isNaN(event.transform.k)) { + event.transform.k = 1; + } + if (isNaN(event.transform.x)) { + event.transform.x = 0; + } + if (isNaN(event.transform.y)) { + event.transform.y = 0; + } + var zoomG = d3.select(selector + ' g'); + zoomG.attr('transform', event.transform); + } + + function resetZoomAndCenter(opts, zoom) { + var zoomG = d3.select(selector); + + // reset zoom scale to 1 + zoomG.call(zoom.scaleTo, 1); + + // the name of this variable is confusing + var zoomGg = d3.select(selector); + var zoomBBox = zoomGg.node().getBBox(); + var w = zoomBBox.width; + var h = zoomBBox.height; + zoomGg + .transition() // required to 'chain' these two instant animations together + .duration(0) + .call(zoom.translateTo, w * 0.5, h * 0.5); + } + + // enable d3 zoom and pan functionality + buildGraphElement.call(zoom); + + var actionResetPan = d3.select('#action-center'); + + // apply zoom onclick + actionResetPan.on('click', e => { + e.preventDefault(); + resetZoomAndCenter(opts, zoom); + }); + + // apply mousedown zoom effects + var g = d3.select('g.node_mousedown'); + if (g.empty()) { + var zoomG = d3.select(selector); + if (!zoomG.node()) { + return null; + } + + var zoomBBox = zoomG.node().getBBox(); + var w = zoomBBox.width; + var h = zoomBBox.height; + g = buildGraphElement.append('g'); + g.classed('node_mousedown', true).attr('id', 'zoom'); + } + + // apply backdrop onclick + buildGraphElement.on('click', e => { + e.preventDefault(); + setTimeout(() => { + opts.onGraphInteraction.send({ + eventType: 'backdrop_click', + }); + }, 0); + }); + + // this centers the graph in the viewbox, or something like that + buildGraphElement = g; + + // draw content into html + buildGraphElement.html(content); + + // recenter on draw, when necessary + if (!opts.isRefreshDraw) { + resetZoomAndCenter(opts, zoom); + } + + if (opts.centerOnDraw) { + resetZoomAndCenter(opts, zoom); + } + + return buildGraphElement; +} + +function drawViewbox(opts, buildGraphElement) { + var graphBBox = buildGraphElement.node().getBBox(); + + // apply viewbox properties to the root element's parent + // provide x padding for the legend + const padding = { x1: 0, x2: 500, y1: 0, y2: 100 }; + + var graphParent = d3.select(buildGraphElement.node().parentNode); + graphParent.attr( + 'viewBox', + '' + + (graphBBox.x - padding.x1) + + ' ' + + (graphBBox.y - padding.y1) + + ' ' + + (graphBBox.width + padding.x2) + + ' ' + + (graphBBox.height + padding.y2), + ); +} + +function drawNodes(opts, buildGraphElement, nodeSelector, edges) { + buildGraphElement.selectAll(nodeSelector).filter(function () { + let node = d3.select(this); + node.select('polygon').classed('d3-build-graph-node', true); + + // apply an outline using rect, since nodes are rect and this will allow for animation + var nodeBBox = node.node().getBBox(); + var outline = node.append('rect'); + outline + .attr('x', nodeBBox.x) + .attr('y', nodeBBox.y) + .attr('width', nodeBBox.width) + .attr('height', nodeBBox.height); + + // extract information embedded in the element id for advanced styling + var data = getNodeDataFromID(node); + + // restore base class and build modifiers + outline.classed('d3-build-graph-node-outline-rect', true); + outline.classed('d3-build-graph-node-outline-' + data.id, true); + outline.classed('-' + data.status, true); + + // apply click-focus styling + if (data.focused && data.focused === 'true') { + outline.classed('-focus', true); + } + + node.on('mouseover', e => { + // apply outline styling + outline.classed('-hover', true); + + // apply styling to edges relevant to this node + edges.filter(edge => { + if (data.id === edge.source || data.id === edge.destination) { + edge.target.classed('-hover', true); + } + }); + }); + + node.on('mouseout', e => { + // remove outline styling + outline.classed('-hover', false); + + // remove styling from edges relevant to this node + edges.filter(edge => { + if (data.id === edge.source || data.id === edge.destination) { + edge.target.classed('-hover', false); + edge.target.classed('-' + edge.status, true); + } + }); + }); + + var stepIconSize = 16; + + node.selectAll('a').filter(function () { + var step = d3.select(this); + if (step.attr('xlink:href').includes('#step:')) { + // restore base class and build modifiers + step.classed('d3-build-graph-node-step-a', true); + + // apply an outline using rect, since nodes are rect and this will allow for animation + var underline = step.append('rect'); + var aBBox = step.node().getBBox(); + underline + .attr('x', aBBox.x + stepIconSize) + .attr('y', aBBox.y + aBBox.height) + .attr('width', aBBox.width - stepIconSize); + + // restore base class and build modifiers + underline.classed('d3-build-graph-node-step-a-underline', true); + + // apply step table row hover styles + step.on('mouseover', e => { + step.classed('-hover', true); + underline.classed('-hover', true); + + // draw underline + underline.attr('height', 1); + }); + step.on('mouseout', e => { + step.classed('-hover', false); + underline.classed('-hover', false); + + // clear underline + underline.attr('height', 0); + }); + } + }); + + // track step number for applying styles + var i = 0; + + // draw node cells (steps) + node.selectAll('#a_node-cell').filter(function () { + var cell = d3.select(this).select('text').node(); + if (cell) { + let cellParent = d3.select(cell.parentNode); + + var step = getStepDataFromTitle(cellParent); + + let cellBBox = cell.getBBox(); + + const iconPadding = { w: 2, h: 2, x: 3, y: 1 }; + + cellParent + .append('rect') + .classed('d3-build-graph-step-icon', true) + .classed('-' + step.status, true) + .attr('width', stepIconSize + iconPadding.w) + .attr('height', stepIconSize + iconPadding.h) + .attr('x', cellBBox.x - iconPadding.x) + .attr('y', cellBBox.y - iconPadding.y) + .attr('rx', '1') + .attr('ry', '1'); + + var stepIcon = cellParent.append('svg'); + stepIcon + .classed('-' + step.status, true) + .attr('viewBox', '0 0 28 28') + .attr('x', cellBBox.x - iconPadding.x) + .attr('y', cellBBox.y - iconPadding.y) + .attr('width', stepIconSize + iconPadding.w) + .attr('height', stepIconSize + iconPadding.h); + + // build the icon svg based on step status + + if (step.status === 'pending') { + stepIcon + .append('circle') + .attr('cx', '14') + .attr('cy', '14') + .attr('r', '2'); + } + + if (step.status === 'running') { + stepIcon.append('path').attr('d', 'M14 7v7.5l5 2.5'); + } + + if (step.status === 'success') { + stepIcon.append('path').attr('d', 'M6 15.9227L10.1026 20 22 7'); + } + + if (step.status === 'failure') { + stepIcon.append('path').attr('d', 'M8 8l12 12M20 8L8 20'); + } + + if (step.status === 'canceled') { + stepIcon.append('path').attr('d', 'M8 8l12 12'); + } + + if (step.status === 'killed') { + stepIcon + .append('circle') + .attr('cx', '9') + .attr('cy', '14') + .attr('r', '2'); + stepIcon + .append('circle') + .attr('cx', '19') + .attr('cy', '14') + .attr('r', '2'); + } + + if (step.status === 'skipped') { + stepIcon + .append('circle') + .attr('cx', '9') + .attr('cy', '14') + .attr('r', '2'); + stepIcon + .append('circle') + .attr('cx', '19') + .attr('cy', '14') + .attr('r', '2'); + } + + if (step.status === 'error') { + stepIcon.append('path').attr('d', 'M14 8v7'); + stepIcon.append('path').attr('d', 'M14 18v2'); + } + + // apply step connector to every step after the first + if (i > 0) { + const connectorPadding = { x: 5.5, y: -6 }; + const connectorSize = { w: 1, h: 4 }; + var connector = cellParent.append('rect'); + connector + .classed('d3-build-graph-step-connector', true) + .attr('x', cellBBox.x + connectorPadding.x) + .attr('y', cellBBox.y + connectorPadding.y) + .attr('width', connectorSize.w) + .attr('height', connectorSize.h); + } + i++; + + // extract and remove the href to dispatch link clicks to Elm + var href = cellParent.attr('xlink:href'); + cellParent.attr('xlink:href', null); + cellParent.on('click', e => { + e.preventDefault(); + // prevents multiple link events getting fired from a single click + e.stopImmediatePropagation(); + setTimeout(() => { + opts.onGraphInteraction.send({ + eventType: 'href', + href: href, + }); + }, 0); + }); + } + }); + }); +} + +function drawEdges(opts, buildGraphElement, edgeSelector) { + // collect edge information to use in other interactivity + var edges: any[] = []; + + buildGraphElement.selectAll(edgeSelector).filter(function () { + d3.select(this).select('ellipse').classed('d3-build-graph-edge-tip', true); + let a = d3.select(this); + var p = a.select('path'); + + // extract information embedded in the element id for advanced styling + var data = getEdgeDataFromID(a); + var edge = { + target: p, + ...data, + }; + edges.push(edge); + + // restore base class and build modifiers + p.classed('d3-build-graph-edge-path', true) + .classed( + 'd3-build-graph-edge-path-' + data.source + '-' + data.destination, + true, + ) + .classed('-' + data.status, true); + + if (data.focused && data.focused === 'true') { + p.classed('-focus', true); + } + }); + + return edges; +} + +// applyOnClickToNodes takes root graph element, selects node links and applies onclick functionality +function applyOnClickToNodes(opts, buildGraphElement, nodeSelector) { + // process and return all 'linked' stage nodes + return buildGraphElement.selectAll(nodeSelector + ' a').filter(function () { + // add onclick to nodes with valid href attributes + var a = d3.select(this); + var href = a.attr('xlink:href'); + a.classed('d3-build-graph-node-a', true); + + if (href !== null) { + d3.select(this).on('click', e => { + e.preventDefault(); + e.stopImmediatePropagation(); + + var nodeA = d3.select(this); + nodeA.attr('xlink:href', null); + + var stageInfo = nodeA.attr('xlink:title').replace('#', '').split(','); + var stageID = '-1'; + if (stageInfo && stageInfo.length == 4) { + stageID = stageInfo[0]; + } + + // dispatch Elm interop + setTimeout(() => { + opts.onGraphInteraction.send({ + eventType: 'node_click', + nodeID: stageID, + }); + }, 0); + }); + } + }); +} + +function getNodeDataFromID(element) { + // extract information embedded in the element id + var id = element.attr('id').replace('#', '').split(','); + + // default info + var data = { + id: '-2', + name: '-', + status: 'pending', + focused: 'false', + }; + + // extract from split id + if (id && id.length == 4) { + data = { + id: id[0], + name: id[1], + status: id[2], + focused: id[3], + }; + } + + return data; +} + +function getEdgeDataFromID(element) { + // extract information embedded in the element id + var id = element.attr('id').replace('#', '').split(','); + + // default info + var data = { + source: '-1', + destination: '-1', + status: 'pending', + focused: 'false', + }; + + // extract from split id + if (id && id.length >= 4) { + data = { + source: id[0], + destination: id[1], + status: id[2], + focused: id[3], + }; + } + + return data; +} + +function getStepDataFromTitle(element) { + // extract information embedded in the element title + var title = element.attr('xlink:title').split(','); + + // default info + var data = { + id: '-3', + name: '-', + status: 'pending', + }; + + // extract from split title + if (title && title.length >= 2) { + data = { + id: title[0], + name: title[1], + status: title[2], + }; + } + + return data; +} diff --git a/src/static/index.d.ts b/src/static/index.d.ts index 5b1578f17..61f4b3327 100644 --- a/src/static/index.d.ts +++ b/src/static/index.d.ts @@ -1,6 +1,4 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - */ +// SPDX-License-Identifier: Apache-2.0 /* Vela Typescript type definitions to encourage end-to-end type safety * @@ -71,6 +69,8 @@ export type Ports = { readonly setRedirect: ToJS; readonly setFavicon: ToJS; readonly onThemeChange: ToElm; + readonly renderBuildGraph: ToJS; + readonly onGraphInteraction: ToElm; }; /** @@ -95,3 +95,33 @@ export type ToElm = { * */ export type Theme = 'theme-light' | 'theme-dark'; + +/** + * Build graph + * + */ +export type GraphData = { + /** @property dot: string */ + dot: string; + /** @property buildID: number */ + buildID: number; + /** @property filter: string */ + filter: string; + /** @property focusedNode: number */ + focusedNode: number; + /** @property showServices: boolean */ + showServices: boolean; + /** @property showSteps: boolean */ + showSteps: boolean; + /** @property centerOnDraw: boolean */ + centerOnDraw: boolean; +}; + +export type GraphInteraction = { + /** @property eventType: string */ + eventType: string; + /** @property href: string */ + href: string; + /** @property nodeID: string */ + nodeID: string; +}; diff --git a/src/static/index.ts b/src/static/index.ts index 94aa82d2b..187b78b4f 100644 --- a/src/static/index.ts +++ b/src/static/index.ts @@ -2,9 +2,13 @@ // import types import * as ClipboardJS from 'clipboard'; +import * as d3 from 'd3'; +import { Graphviz } from '@hpcc-js/wasm'; + import { Elm } from '../elm/Main.elm'; import '../scss/style.scss'; import { App, Config, Flags, Theme } from './index.d'; +import * as Graph from './graph'; // Vela consts const feedbackURL: string = @@ -123,3 +127,32 @@ function envOrNull(env: string, subst: string): string | null { // value was substituted, return it return subst; } + +// track rendering options globally to help determine draw logic +var opts = { + currentBuild: -1, + isRefreshDraw: false, + centerOnDraw: false, + contentFilter: '', + onGraphInteraction: {}, +}; + +app.ports.renderBuildGraph.subscribe(function (graphData) { + const graphviz = Graphviz.load().then(res => { + var content = res.layout(graphData.dot, 'svg', 'dot'); + // construct graph building options + opts.isRefreshDraw = opts.currentBuild === graphData.buildID; + opts.centerOnDraw = graphData.centerOnDraw; + opts.contentFilter = graphData.filter; + + // track the currently drawn build + opts.currentBuild = graphData.buildID; + + opts.onGraphInteraction = app.ports.onGraphInteraction; + + // dispatch the draw command to avoid elm/js rendering race condition + setTimeout(() => { + Graph.drawGraph(opts, content); + }, 0); + }); +});