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()