diff --git a/.github/workflows/windows-wheel.yml b/.github/workflows/windows-wheel.yml index 77cf7c5..524a65f 100644 --- a/.github/workflows/windows-wheel.yml +++ b/.github/workflows/windows-wheel.yml @@ -32,6 +32,7 @@ jobs: run: | blast-radius --help python -m unittest discover -s tests -v + node --test tests/frontend/*.test.js - name: Verify serve mode run: | blast-radius --serve --host 127.0.0.1 --port 5055 . >server.log 2>&1 & diff --git a/README.md b/README.md index 1f25243..6bf5827 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ And you will shortly be rewarded with a browser link http://127.0.0.1:5000/. Note: If you do not have an initialized Terraform directory but have the DOT script (the output of the `terraform graph` command, note that this is not the same as a JSON file or state graph). You can either copy and paste the DOT script into the text input field or uploaded the DOT script file. +To prune a large graph, select a resource by clicking it or choosing it in the +search field, then select the red **Prune to selection** button. The resulting +view keeps the selected resource, its dependencies, and resources that depend +on it. Each tab is pruned from its own original graph, including uploaded and +pasted DOT documents. + Other ways to run it include [Docker](#docker-quickstart) and [Kubernetes](#kubernetes-quickstart) ## Docker Quickstart diff --git a/blastradius/handlers/dot.py b/blastradius/handlers/dot.py index 54b12f8..32bdcaa 100644 --- a/blastradius/handlers/dot.py +++ b/blastradius/handlers/dot.py @@ -244,31 +244,33 @@ def center(self, node): edges_by_target[e.target] = [ e ] edges_to_save = OrderedSet() # edge objects - nodes_to_save = OrderedSet() # label strings - - q = deque() - if node.label in edges_by_source: - q.append(node.label) - nodes_to_save.add(node.label) - while len(q) > 0: - source = q.pop() - if source in edges_by_source: - for e in edges_by_source[source]: - q.append(e.target) - edges_to_save.add(e) - nodes_to_save.add(e.target) - - q = deque() - if node.label in edges_by_target: - q.append(node.label) - nodes_to_save.add(node.label) - while len(q) > 0: - target = q.pop() - if target in edges_by_target: - for e in edges_by_target[target]: - q.append(e.source) - edges_to_save.add(e) - nodes_to_save.add(e.source) + nodes_to_save = OrderedSet([node.label]) # label strings + + q = deque([node.label]) + visited = set() + while q: + source = q.popleft() + if source in visited: + continue + visited.add(source) + for e in edges_by_source.get(source, []): + edges_to_save.add(e) + nodes_to_save.add(e.target) + if e.target not in visited: + q.append(e.target) + + q = deque([node.label]) + visited = set() + while q: + target = q.popleft() + if target in visited: + continue + visited.add(target) + for e in edges_by_target.get(target, []): + edges_to_save.add(e) + nodes_to_save.add(e.source) + if e.source not in visited: + q.append(e.source) self.edges = list(edges_to_save) self.nodes = [ n for n in self.nodes if n.label in nodes_to_save ] @@ -285,19 +287,20 @@ def focus(self, node): edges_by_source[e.source] = [ e ] edges_to_save = OrderedSet() # edge objects - nodes_to_save = OrderedSet() # label strings - - q = deque() - if node.label in edges_by_source: - q.append(node.label) - nodes_to_save.add(node.label) - while len(q) > 0: - source = q.pop() - if source in edges_by_source: - for e in edges_by_source[source]: - q.append(e.target) - edges_to_save.add(e) - nodes_to_save.add(e.target) + nodes_to_save = OrderedSet([node.label]) # label strings + + q = deque([node.label]) + visited = set() + while q: + source = q.popleft() + if source in visited: + continue + visited.add(source) + for e in edges_by_source.get(source, []): + edges_to_save.add(e) + nodes_to_save.add(e.target) + if e.target not in visited: + q.append(e.target) self.edges = list(edges_to_save) self.nodes = [ n for n in self.nodes if n.label in nodes_to_save ] @@ -521,4 +524,3 @@ def __init__(self, source, target, fmt=None, edge_type=EdgeType.NORMAL): def __iter__(self): for key in {'source', 'target', 'svg_id', 'edge_type'}: yield (key, getattr(self, key)) - diff --git a/blastradius/server/static/js/blast-radius.js b/blastradius/server/static/js/blast-radius.js index 440d102..a2b830d 100644 --- a/blastradius/server/static/js/blast-radius.js +++ b/blastradius/server/static/js/blast-radius.js @@ -12,6 +12,10 @@ const edge_types = { LAYOUT_HIDDEN: 4, // these edges are not drawn, aren't "real" edges, but inform layout. } +// Each interactive tab owns its source, render options, and presentation state. +// Pasted and uploaded graphs must always be re-rendered from this original DOT. +const graphSources = new Map(); + // Sometimes we have escaped newlines (\n) in json strings. we want
instead. // FIXME: much better line wrapping is probably possible. var replacer = function (key, value) { @@ -53,14 +57,30 @@ let clearGraphMessage = (selector) => { } } -let renderDotSource = async (dot, selector) => { +let renderDotSource = async (dot, selector, options = {}, beforeReplace = null) => { try { + let previousSource = graphSources.get(selector); + let renderOptions = Object.assign( + {}, + previousSource && previousSource.type === "dot" + ? previousSource.options + : {}, + options + ); + let requestBody = {dot: dot}; + if (renderOptions.module_depth !== undefined) { + requestBody.module_depth = renderOptions.module_depth; + } + if (renderOptions.refocus !== undefined) { + requestBody.refocus = renderOptions.refocus; + } + let response = await fetch("/api/graphs/render", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({dot: dot}) + body: JSON.stringify(requestBody) }); let payload = await response.json(); @@ -76,12 +96,28 @@ let renderDotSource = async (dot, selector) => { throw new Error("Graphviz returned an invalid SVG document."); } + let br_state = previousSource && previousSource.type === "dot" + ? previousSource.br_state + : {}; + graphSources.set(selector, { + type: "dot", + dot: dot, + options: renderOptions, + br_state: br_state + }); + + if (beforeReplace) { + beforeReplace(); + } + let previousSvg = document.querySelector(`${selector} svg`); + if (previousSvg) { + previousSvg.remove(); + } clearGraphMessage(selector); - let br_state = {}; await blastradius( selector, - "/graph.svg", - "/graph.json", + null, + null, br_state, xml, payload.graph @@ -208,6 +244,7 @@ let displayTabContent = (tabNumber, color) => { */ let closeTab = (tabNumber) => { + graphSources.delete(`#graph-${tabNumber}`); $(`#tab-${tabNumber}`).remove(); $(`#nav-item-${tabNumber}`).remove(); $(`.graph-${tabNumber}-d3-tip`).remove(); @@ -362,6 +399,15 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = var state = br_state[selector]; var container = d3.select(selector); + if (!graphSources.has(selector)) { + graphSources.set(selector, { + type: "terraform", + svg_url: svg_url, + json_url: json_url, + options: state.params || {}, + br_state: br_state + }); + } // color assignments (resource_type : rgb) are stateful. If we use a new palette // every time the a subgraph is selected, the color assignments would differ and @@ -372,11 +418,7 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = // 1st pull down the svg, and append it to the DOM as a child // of our selector. If added as , we wouldn't // be able to manipulate x.svg with d3.js, or other DOM fns. - d3.xml(svg_url, function (error, xml) { - - if (uploadXML != null) { - xml = uploadXML; - } + let renderXml = function (error, xml) { if (error && uploadXML == null) { showGraphMessage(selector, "The graph SVG could not be loaded."); return; @@ -405,11 +447,7 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = // Obtain the graph description. Doing this within the // d3.xml success callback, to gurantee the svg/xml // has loaded. - d3.json(json_url, function (error, data) { - - if (uploadJSON !== null) { - data = uploadJSON - } + let renderJson = function (error, data) { if (error && uploadJSON === null) { console.error("No Terraform files were found, so JSON details will not be available. The graph is still usable but without all features enabled such as filtering content"); @@ -855,6 +893,19 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = var handle_refocus = function () { if (sticky_node) { + let source = graphSources.get(selector); + if (source && source.type === "dot") { + renderDotSource( + source.dot, + selector, + Object.assign({}, source.options, { + refocus: sticky_node.label + }), + clear_listeners + ); + return; + } + $(selector + ' svg').remove(); clear_listeners(); if (!state['params']) { @@ -1015,7 +1066,19 @@ blastradius = function (selector, svg_url, json_url, br_state = {}, uploadXML = }); } // end if(interactive) - }); // end json success callback - }); // end svg success callback + }; // end json success callback + + if (uploadJSON !== null) { + renderJson(null, uploadJSON); + } else { + d3.json(json_url, renderJson); + } + }; // end svg success callback + + if (uploadXML !== null) { + renderXml(null, uploadXML); + } else { + d3.xml(svg_url, renderXml); + } } // end blastradius() diff --git a/tests/frontend/graph-pruning.test.js b/tests/frontend/graph-pruning.test.js new file mode 100644 index 0000000..362843d --- /dev/null +++ b/tests/frontend/graph-pruning.test.js @@ -0,0 +1,102 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const test = require("node:test"); +const vm = require("node:vm"); + +const source = fs.readFileSync( + "blastradius/server/static/js/blast-radius.js", + "utf8" +); + +function createBrowserContext() { + const requests = []; + const renders = []; + const context = { + console, + document: { + querySelector(query) { + if (query.endsWith(" svg")) { + return {remove() {}}; + } + return null; + } + }, + DOMParser: class { + parseFromString() { + return { + documentElement: {}, + querySelector() { + return null; + } + }; + } + }, + fetch: async (_url, options) => { + requests.push(JSON.parse(options.body)); + return { + ok: true, + async json() { + return { + svg: "", + graph: {nodes: [], edges: []}, + warnings: [] + }; + } + }; + }, + Map, + Set + }; + + vm.createContext(context); + vm.runInContext(source, context); + context.blastradius = async (...arguments_) => { + renders.push(arguments_); + }; + return {context, renders, requests}; +} + +function render(context, dot, selector, options = {}) { + return vm.runInContext( + `renderDotSource( + ${JSON.stringify(dot)}, + ${JSON.stringify(selector)}, + ${JSON.stringify(options)} + )`, + context + ); +} + +test("each tab prunes its own original DOT and retains render options", async () => { + const {context, renders, requests} = createBrowserContext(); + const uploadedDot = 'digraph { "upload.a" -> "upload.b" }'; + const pastedDot = 'digraph { "paste.a" -> "paste.b" }'; + + await render(context, uploadedDot, "#graph-2", {module_depth: 1}); + await render(context, pastedDot, "#graph-3"); + await vm.runInContext( + `renderDotSource( + graphSources.get("#graph-2").dot, + "#graph-2", + Object.assign({}, graphSources.get("#graph-2").options, { + refocus: "upload.a" + }) + )`, + context + ); + + assert.equal(requests.length, 3); + assert.deepEqual(requests[2], { + dot: uploadedDot, + module_depth: 1, + refocus: "upload.a" + }); + assert.equal( + vm.runInContext('graphSources.get("#graph-3").dot', context), + pastedDot + ); + assert.equal(renders.length, 3); + assert.equal(renders[2][0], "#graph-2"); + assert.equal(renders[2][1], null); + assert.equal(renders[2][2], null); +}); diff --git a/tests/test_graph_pruning.py b/tests/test_graph_pruning.py new file mode 100644 index 0000000..477d4f5 --- /dev/null +++ b/tests/test_graph_pruning.py @@ -0,0 +1,85 @@ +import unittest +from unittest.mock import patch + +from blastradius.handlers.dot import DotGraph +from blastradius.server import server + +CYCLIC_DOT = """\ +digraph { + "[root] aws_instance.a" [label = "aws_instance.a"] + "[root] aws_instance.b" [label = "aws_instance.b"] + "[root] aws_instance.isolated" [label = "aws_instance.isolated"] + "[root] aws_instance.a" -> "[root] aws_instance.b" + "[root] aws_instance.b" -> "[root] aws_instance.a" +} +""" + + +class GraphPruningTests(unittest.TestCase): + def test_center_is_cycle_safe(self): + graph = DotGraph("", file_contents=CYCLIC_DOT) + + graph.center(graph.get_node_by_name("[root] aws_instance.a")) + + self.assertEqual( + {node.label for node in graph.nodes}, + { + "[root] aws_instance.a", + "[root] aws_instance.b", + }, + ) + self.assertEqual(len(graph.edges), 2) + + def test_center_preserves_an_isolated_selected_node(self): + graph = DotGraph("", file_contents=CYCLIC_DOT) + + graph.center(graph.get_node_by_name("[root] aws_instance.isolated")) + + self.assertEqual( + [node.label for node in graph.nodes], + ["[root] aws_instance.isolated"], + ) + self.assertEqual(graph.edges, []) + + @patch.object(server.DotGraph, "svg", return_value="") + def test_api_prunes_the_submitted_dot_source(self, _svg): + response = server.app.test_client().post( + "/api/graphs/render", + json={ + "dot": CYCLIC_DOT, + "refocus": "[root] aws_instance.a", + }, + ) + + self.assertEqual(response.status_code, 200) + graph = response.get_json()["graph"] + self.assertEqual( + {node["label"] for node in graph["nodes"]}, + { + "[root] aws_instance.a", + "[root] aws_instance.b", + }, + ) + self.assertEqual(len(graph["edges"]), 2) + + @patch.object(server.DotGraph, "svg", return_value="") + def test_api_preserves_isolated_selected_node(self, _svg): + response = server.app.test_client().post( + "/api/graphs/render", + json={ + "dot": CYCLIC_DOT, + "refocus": "[root] aws_instance.isolated", + }, + ) + + self.assertEqual(response.status_code, 200) + graph = response.get_json()["graph"] + self.assertEqual( + [node["label"] for node in graph["nodes"]], + ["[root] aws_instance.isolated"], + ) + self.assertEqual(graph["edges"], []) + + +if __name__ == "__main__": + unittest.main()