Skip to content

Commit

Permalink
feat: Open node file when double-clicking on it from a browser (#79)
Browse files Browse the repository at this point in the history
  • Loading branch information
haidaraM authored Jan 29, 2022
1 parent 2ce6644 commit 826c6ef
Show file tree
Hide file tree
Showing 13 changed files with 394 additions and 76 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# 1.1.0 (unreleased)

- fix: Do not pass display as param since it's a singleton + init locale to fix warning
- feat: Open node file when double-clicking on it from a
browser [\#79](https://github.com/haidaraM/ansible-playbook-grapher/pull/79)

- **Full Changelog**: https://github.com/haidaraM/ansible-playbook-grapher/compare/v1.0.2...v1.1.0

# 1.0.2 (2022-01-16)

Expand Down
39 changes: 20 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,11 @@ regarding the blocks.
The available options:

```
$ ansible-playbook-grapher --help
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY]
[--include-role-tasks] [-s] [--view]
[-o OUTPUT_FILENAME] [--version] [-t TAGS]
[--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES]
[-e EXTRA_VARS]
ansible-playbook-grapher --help
usage: ansible-playbook-grapher [-h] [-v] [-i INVENTORY] [--include-role-tasks] [-s] [--view] [-o OUTPUT_FILENAME]
[--open-protocol-handler {default,vscode,custom}] [--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS] [--version]
[-t TAGS] [--skip-tags SKIP_TAGS] [--vault-id VAULT_IDS]
[--ask-vault-password | --vault-password-file VAULT_PASSWORD_FILES] [-e EXTRA_VARS]
playbook
Make graphs from your Ansible Playbooks.
Expand All @@ -79,29 +77,32 @@ optional arguments:
--ask-vault-password, --ask-vault-pass
ask for vault password
--include-role-tasks Include the tasks of the role in the graph.
--open-protocol-custom-formats OPEN_PROTOCOL_CUSTOM_FORMATS
The custom formats to use as URLs for the nodes in the graph. Required if --open-protocol-handler is set to custom. You should
provide a JSON formatted string like: {"file": "", "folder": ""}. Example: If you want to open folders (roles) inside the browser
and files (tasks) in vscode, set this to '{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
--open-protocol-handler {default,vscode,custom}
The protocol to use to open the nodes when double-clicking on them in your SVG viewer. Your SVG viewer must support double-click
and Javascript. The supported values are 'default', 'vscode' and 'custom'. For 'default', the URL will be the path to the file or
folders. When using a browser, it will open or download them. For 'vscode', the folders and files will be open with VSCode. For
'custom', you need to set a custom format with --open-protocol-custom-formats.
--skip-tags SKIP_TAGS
only run plays and tasks whose tags do not match these
values
only run plays and tasks whose tags do not match these values
--vault-id VAULT_IDS the vault identity to use
--vault-password-file VAULT_PASSWORD_FILES, --vault-pass-file VAULT_PASSWORD_FILES
vault password file
--version show program's version number and exit
--view Automatically open the resulting SVG file with your
system’s default viewer application for the file type
--view Automatically open the resulting SVG file with your system’s default viewer application for the file type
-e EXTRA_VARS, --extra-vars EXTRA_VARS
set additional variables as key=value or YAML/JSON, if
filename prepend with @
set additional variables as key=value or YAML/JSON, if filename prepend with @
-h, --help show this help message and exit
-i INVENTORY, --inventory INVENTORY
specify inventory host path or comma separated host
list.
specify inventory host path or comma separated host list.
-o OUTPUT_FILENAME, --output-file-name OUTPUT_FILENAME
Output filename without the '.svg' extension. Default:
<playbook>.svg
Output filename without the '.svg' extension. Default: <playbook>.svg
-s, --save-dot-file Save the dot file used to generate the graph.
-t TAGS, --tags TAGS only run plays and tasks tagged with these values
-v, --verbose verbose mode (-vvv for more, -vvvv to enable
connection debugging)
-v, --verbose verbose mode (-vvv for more, -vvvv to enable connection debugging)
```

## Configuration: ansible.cfg
Expand Down
63 changes: 57 additions & 6 deletions ansibleplaybookgrapher/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,26 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import json
import ntpath
import os
import sys
from abc import ABC

from ansible.cli import CLI
from ansible.cli.arguments import option_helpers
from ansible.errors import AnsibleOptionsError
from ansible.release import __version__ as ansible_version
from ansible.utils.display import Display, initialize_locale

from ansibleplaybookgrapher import __prog__, __version__
from ansibleplaybookgrapher.parser import PlaybookParser
from ansibleplaybookgrapher.postprocessor import GraphVizPostProcessor
from ansibleplaybookgrapher.renderer import GraphvizRenderer
from ansibleplaybookgrapher.renderer import GraphvizRenderer, OPEN_PROTOCOL_HANDLERS

# The display is a singleton. This instruction will NOT return a new instance.
# We explicitly set the verbosity after the init.
display = Display()


def get_cli_class():
Expand All @@ -45,9 +51,6 @@ class GrapherCLI(CLI, ABC):
def run(self):
super(GrapherCLI, self).run()

# The display is a singleton. This instruction will NOT return a new instance.
# We explicitly set the verbosity after the init.
display = Display()
# Required to fix the warning "ansible.utils.display.initialize_locale has not been called..."
initialize_locale()
display.verbosity = self.options.verbosity
Expand All @@ -57,7 +60,8 @@ def run(self):
include_role_tasks=self.options.include_role_tasks)

playbook_node = parser.parse()
renderer = GraphvizRenderer(playbook_node)
renderer = GraphvizRenderer(playbook_node, open_protocol_handler=self.options.open_protocol_handler,
open_protocol_custom_formats=self.options.open_protocol_custom_formats)
svg_path = renderer.render(self.options.output_filename, self.options.save_dot_file, self.options.view)

post_processor = GraphVizPostProcessor(svg_path=svg_path)
Expand Down Expand Up @@ -86,7 +90,6 @@ def __init__(self, args, callback=None):
def _add_my_options(self):
"""
Add some of my options to the parser
:param parser:
:return:
"""
self.parser.prog = __prog__
Expand All @@ -106,6 +109,26 @@ def _add_my_options(self):
self.parser.add_argument("-o", "--output-file-name", dest='output_filename',
help="Output filename without the '.svg' extension. Default: <playbook>.svg")

self.parser.add_argument("--open-protocol-handler", dest="open_protocol_handler",
choices=list(OPEN_PROTOCOL_HANDLERS.keys()), default="default",
help="""The protocol to use to open the nodes when double-clicking on them in your SVG
viewer. Your SVG viewer must support double-click and Javascript.
The supported values are 'default', 'vscode' and 'custom'.
For 'default', the URL will be the path to the file or folders. When using a browser,
it will open or download them.
For 'vscode', the folders and files will be open with VSCode.
For 'custom', you need to set a custom format with --open-protocol-custom-formats.
""")

self.parser.add_argument("--open-protocol-custom-formats", dest="open_protocol_custom_formats", default=None,
help="""The custom formats to use as URLs for the nodes in the graph. Required if
--open-protocol-handler is set to custom.
You should provide a JSON formatted string like: {"file": "", "folder": ""}.
Example: If you want to open folders (roles) inside the browser and files (tasks) in
vscode, set this to
'{"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
""")

self.parser.add_argument('--version', action='version',
version="%s %s (with ansible %s)" % (__prog__, __version__, ansible_version))

Expand All @@ -132,8 +155,36 @@ def post_process_args(self, options):
# use the playbook name (without the extension) as output filename
self.options.output_filename = os.path.splitext(ntpath.basename(self.options.playbook_filename))[0]

if self.options.open_protocol_handler == "custom":
self.validate_open_protocol_custom_formats()

return options

def validate_open_protocol_custom_formats(self):
"""
Validate the provided open protocol format
:return:
"""
error_msg = 'Make sure to provide valid formats. Example: {"file": "vscode://file/{path}:{line}:{column}", "folder": "{path}"}'
format_str = self.options.open_protocol_custom_formats
if not format_str:
raise AnsibleOptionsError("When the protocol handler is to set to custom, you must provide the formats to "
"use with --open-protocol-custom-formats.")
try:
format_dict = json.loads(format_str)
except Exception as e:
display.error(f"{type(e).__name__} when reading the provided formats '{format_str}': {e}")
display.error(error_msg)
sys.exit(1)

if "file" not in format_dict or "folder" not in format_dict:
display.error(f"The field 'file' or 'folder' is missing from the provided format '{format_str}'")
display.error(error_msg)
sys.exit(1)

# Replace the string with a dict
self.options.open_protocol_custom_formats = format_dict


def main(args=None):
args = args or sys.argv
Expand Down
43 changes: 37 additions & 6 deletions ansibleplaybookgrapher/data/highlight-hover.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,42 +52,44 @@ function unHighlightLinkedNodes(rootElement, isHover) {
// Recursively unhighlight
unHighlightLinkedNodes(linkedElement, isHover);
}

})
}

}

/**
* Hover handler for mouseenter event
* @param event
* @param {Event} event
*/
function hoverMouseEnter(event) {
highlightLinkedNodes(event.currentTarget);
}

/**
* Hover handler for mouseleave event
* @param event
* @param {Event} event
*/
function hoverMouseLeave(event) {
unHighlightLinkedNodes(event.currentTarget, true);
}

/**
* Handler when clicking on some elements
* @param event
* @param {Event} event
*/
function clickOnElement(event) {
let newClickedElement = $(event.currentTarget);
const newClickedElement = $(event.currentTarget);

event.preventDefault(); // Disable the default click behavior since we override it here

if (newClickedElement.attr('id') === $(currentSelectedElement).attr('id')) { // clicking again on the same element
newClickedElement.removeClass(HIGHLIGHT_CLASS);
unHighlightLinkedNodes(currentSelectedElement, false);
currentSelectedElement = null;
} else { // clicking on a different node

// Remove highlight from all the nodes linked the current selected node
// Remove highlight from all the nodes linked to the current selected node
unHighlightLinkedNodes(currentSelectedElement, false);
if (currentSelectedElement) {
currentSelectedElement.removeClass(HIGHLIGHT_CLASS);
Expand All @@ -99,22 +101,51 @@ function clickOnElement(event) {
}
}

/**
* Handler when double clicking on some elements
* @param {Event} event
*/
function dblClickElement(event) {
const newElementDlbClicked = event.currentTarget;
const links = $(newElementDlbClicked).find("a[xlink\\:href]");

if (links.length > 0) {
const targetLink = $(links[0]).attr("xlink:href");
document.location = targetLink;
} else {
console.log("No links found on this element");
}
}


$("#svg").ready(function () {
let playbook = $("g[id^=playbook_]");
let plays = $("g[id^=play_]");
let roles = $("g[id^=role_]");
let blocks = $("g[id^=block_]");
let tasks = $("g[id^=pre_task_], g[id^=task_], g[id^=post_task_]");

playbook.click(clickOnElement);
playbook.dblclick(dblClickElement);

// Set hover and click events on the plays
plays.hover(hoverMouseEnter, hoverMouseLeave);
plays.click(clickOnElement);
plays.dblclick(dblClickElement);

// Set hover and click events on the roles
roles.hover(hoverMouseEnter, hoverMouseLeave);
roles.click(clickOnElement);
roles.dblclick(dblClickElement);

// Set hover and click events on the blocks
blocks.hover(hoverMouseEnter, hoverMouseLeave);
blocks.click(clickOnElement);
blocks.dblclick(dblClickElement);

// Set hover and click events on the tasks
tasks.hover(hoverMouseEnter, hoverMouseLeave);
tasks.click(clickOnElement);
tasks.dblclick(dblClickElement);

});
46 changes: 44 additions & 2 deletions ansibleplaybookgrapher/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import os
from collections import defaultdict
from typing import Dict, List, ItemsView

Expand All @@ -28,12 +29,26 @@ def __init__(self, node_name: str, node_id: str, raw_object=None):
:param node_name: The name of the node
:param node_id: An identifier for this node
:param raw_object: The raw ansible object matching this node in the graph. Will be None if there is no match on
:param raw_object: The raw ansible object matching this node in the graph. Will be None if there is no match on
Ansible side
"""
self.name = node_name
self.id = node_id
self.raw_object = raw_object
# Trying to get the object position in the parsed files. Format: (path,line,column)
self.path = self.line = self.column = None
self.retrieve_position()

def retrieve_position(self):
"""
Set the path of this based on the raw object. Not all objects have path
:return:
"""
if self.raw_object and self.raw_object.get_ds():
self.path, self.line, self.column = self.raw_object.get_ds().ansible_pos

def __str__(self):
return f"{type(self).__name__}(name='{self.name}')"

def __repr__(self):
return f"{type(self).__name__}(id='{self.id}',name='{self.name}')"
Expand Down Expand Up @@ -142,6 +157,16 @@ def __init__(self, node_name: str, node_id: str = None, raw_object=None):
super().__init__(node_name, node_id or generate_id("playbook_"), raw_object=raw_object,
supported_compositions=["plays"])

def retrieve_position(self):
"""
Playbooks only have path as position
:return:
"""
# Since the playbook is the whole file, the set the position as the beginning of the file
self.path = os.path.join(os.getcwd(), self.name)
self.line = 1
self.column = 1

@property
def plays(self) -> List['EdgeNode']:
"""
Expand Down Expand Up @@ -252,6 +277,12 @@ class TaskNode(Node):
"""

def __init__(self, node_name: str, node_id: str = None, raw_object=None):
"""
:param node_name:
:param node_id:
:param raw_object:
"""
super().__init__(node_name, node_id or generate_id("task_"), raw_object)


Expand All @@ -260,8 +291,19 @@ class RoleNode(CompositeTasksNode):
A role node. A role is a composition of tasks
"""

def __init__(self, node_name: str, node_id: str = None, raw_object=None):
def __init__(self, node_name: str, node_id: str = None, raw_object=None, include_role: bool = False):
"""
:param node_name:
:param node_id:
:param raw_object:
"""
super().__init__(node_name, node_id or generate_id("role_"), raw_object=raw_object)
self.include_role = include_role
if raw_object and not include_role:
# If it's not an include_role, we take the role path which the path to the folder where the role is located
# on the disk
self.path = raw_object._role_path


def _get_all_tasks_nodes(composite: CompositeNode, task_acc: List[TaskNode]):
Expand Down
Loading

0 comments on commit 826c6ef

Please sign in to comment.