Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/windows-wheel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 &
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 41 additions & 39 deletions blastradius/handlers/dot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
Expand All @@ -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 ]
Expand Down Expand Up @@ -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))

97 changes: 80 additions & 17 deletions blastradius/server/static/js/blast-radius.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <br> instead.
// FIXME: much better line wrapping is probably possible.
var replacer = function (key, value) {
Expand Down Expand Up @@ -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();

Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand All @@ -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 <img src="x.svg">, 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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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()
102 changes: 102 additions & 0 deletions tests/frontend/graph-pruning.test.js
Original file line number Diff line number Diff line change
@@ -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: "<svg></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);
});
Loading
Loading