Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Open node file when double-clicking on it from a browser #79

Merged
merged 12 commits into from
Jan 29, 2022
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