From 04722ad82495f04e9ebe91409ecc7f794e50a3a7 Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Wed, 27 Dec 2017 22:01:08 +0000 Subject: [PATCH 01/63] First pass at state machite visualisation --- routemaster/server/endpoints.py | 27 ++++++++++++++++++- routemaster/state_machine/__init__.py | 2 ++ .../state_machine/tests/test_visualisation.py | 5 ++++ routemaster/state_machine/visualisation.py | 17 ++++++++++++ setup.py | 1 + 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 routemaster/state_machine/tests/test_visualisation.py create mode 100644 routemaster/state_machine/visualisation.py diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py index a65bdc32..bd61b3ef 100644 --- a/routemaster/server/endpoints.py +++ b/routemaster/server/endpoints.py @@ -1,6 +1,6 @@ """Core API endpoints for routemaster service.""" -from flask import Flask, abort, jsonify, request +from flask import Flask, Response, abort, jsonify, request from routemaster import state_machine from routemaster.state_machine import ( @@ -8,6 +8,7 @@ UnknownLabel, LabelAlreadyExists, UnknownStateMachine, + draw_state_machine, ) server = Flask('routemaster') @@ -34,6 +35,7 @@ def get_state_machines(): 'state-machines': [ { 'name': x.name, + 'view': f'/state-machines/{x.name}/view', 'labels': f'/state-machines/{x.name}/labels', } for x in server.config.app.config.state_machines.values() @@ -41,6 +43,29 @@ def get_state_machines(): }) +@server.route('/state-machines//view', methods=['GET']) +def view_state_machine(state_machine_name): + """ + Render an image of a state machine. + + Returns: + - 200 Ok, SVG: if the state machine exists. + - 404 Not Found: if the state machine does not exist. + """ + app = server.config.app + + try: + state_machine = app.config.state_machines[state_machine_name] + except KeyError as k: + msg = f"State machine '{state_machine_name}' does not exist" + abort(404, msg) + + return Response( + draw_state_machine(state_machine), + mimetype='image/svg+xml', + ) + + @server.route( '/state-machines//labels', methods=['GET'], diff --git a/routemaster/state_machine/__init__.py b/routemaster/state_machine/__init__.py index cc61f946..aebcf6ee 100644 --- a/routemaster/state_machine/__init__.py +++ b/routemaster/state_machine/__init__.py @@ -14,6 +14,7 @@ LabelAlreadyExists, UnknownStateMachine, ) +from routemaster.state_machine.visualisation import draw_state_machine __all__ = ( 'Label', @@ -23,6 +24,7 @@ 'UnknownLabel', 'get_label_state', 'get_label_metadata', + 'draw_state_machine', 'LabelAlreadyExists', 'UnknownStateMachine', 'update_metadata_for_label', diff --git a/routemaster/state_machine/tests/test_visualisation.py b/routemaster/state_machine/tests/test_visualisation.py new file mode 100644 index 00000000..80001e76 --- /dev/null +++ b/routemaster/state_machine/tests/test_visualisation.py @@ -0,0 +1,5 @@ +from routemaster.state_machine import draw_state_machine + + +def test_draw_state_machine(app_config): + draw_state_machine(app_config.config.state_machines['test_machine']) diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py new file mode 100644 index 00000000..5b178df2 --- /dev/null +++ b/routemaster/state_machine/visualisation.py @@ -0,0 +1,17 @@ +"""Visualisation code for state machines.""" + +import pydot + +from routemaster.config import StateMachine + + +def draw_state_machine(state_machine: StateMachine) -> bytes: + """Produce an SVG drawing of a state machine.""" + graph = pydot.Dot(graph_type='graph') + + for state in state_machine.states: + graph.add_node(pydot.Node(state.name)) + for destination_name in state.next_states.all_destinations(): + graph.add_edge(pydot.Edge(state.name, destination_name)) + + return graph.create(format='svg') diff --git a/setup.py b/setup.py index 4b442741..14f9a980 100644 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ 'python-dateutil', 'alembic >=0.9.6', 'gunicorn >=19.7', + 'pydot', ), setup_requires=( From 9f15757f20224cc0e43fdf72b87bdc14fd18bccd Mon Sep 17 00:00:00 2001 From: Dan Palmer Date: Wed, 27 Dec 2017 22:51:46 +0000 Subject: [PATCH 02/63] Improve styling --- routemaster/state_machine/visualisation.py | 24 +++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py index 5b178df2..dee5240c 100644 --- a/routemaster/state_machine/visualisation.py +++ b/routemaster/state_machine/visualisation.py @@ -2,16 +2,30 @@ import pydot -from routemaster.config import StateMachine +from routemaster.config import Action, StateMachine def draw_state_machine(state_machine: StateMachine) -> bytes: """Produce an SVG drawing of a state machine.""" - graph = pydot.Dot(graph_type='graph') + graph = pydot.Dot( + graph_type='graph', + label=state_machine.name, + labelloc='t', + labeljust='l', + ) for state in state_machine.states: - graph.add_node(pydot.Node(state.name)) - for destination_name in state.next_states.all_destinations(): - graph.add_edge(pydot.Edge(state.name, destination_name)) + node_colour = 'red' if isinstance(state, Action) else 'blue' + graph.add_node(pydot.Node(state.name, color=node_colour, shape='rect')) + + all_destinations = state.next_states.all_destinations() + for destination_name in all_destinations: + edge = pydot.Edge( + state.name, + destination_name, + dir='forward', + style='dashed' if len(all_destinations) > 1 else 'solid', + ) + graph.add_edge(edge) return graph.create(format='svg') From 83ee7acad66ee1947cfeb6c74971fd034f848961 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 18 Apr 2018 17:40:30 +0100 Subject: [PATCH 03/63] Clarify that this is a collection --- routemaster/config/model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/routemaster/config/model.py b/routemaster/config/model.py index d1a70a60..151a4f1d 100644 --- a/routemaster/config/model.py +++ b/routemaster/config/model.py @@ -11,6 +11,7 @@ Pattern, Iterable, Sequence, + Collection, NamedTuple, ) @@ -64,7 +65,7 @@ def next_state_for_label(self, label_context: Any) -> str: """Returns the constant next state.""" return self.state - def all_destinations(self) -> Iterable[str]: + def all_destinations(self) -> Collection[str]: """Returns the constant next state.""" return [self.state] From 698f2f8152e50098aa6bbc0fa328e4f35f4febee Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 18 Apr 2018 17:41:53 +0100 Subject: [PATCH 04/63] Expect this endpoint to exist too --- routemaster/server/tests/test_endpoints.py | 1 + 1 file changed, 1 insertion(+) diff --git a/routemaster/server/tests/test_endpoints.py b/routemaster/server/tests/test_endpoints.py index 9e175f2c..a6adef96 100644 --- a/routemaster/server/tests/test_endpoints.py +++ b/routemaster/server/tests/test_endpoints.py @@ -36,6 +36,7 @@ def test_enumerate_state_machines(client, app_config): { 'name': state_machine.name, 'labels': f'/state-machines/{state_machine.name}/labels', + 'view': f'/state-machines/{state_machine.name}/view', } for state_machine in app_config.config.state_machines.values() ]} From 9a531b4a7612b765f715dd7287b5fb18e3ece85b Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 18 Apr 2018 20:17:51 +0100 Subject: [PATCH 05/63] These are collections too --- routemaster/config/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routemaster/config/model.py b/routemaster/config/model.py index 151a4f1d..787baade 100644 --- a/routemaster/config/model.py +++ b/routemaster/config/model.py @@ -90,7 +90,7 @@ def next_state_for_label(self, label_context: 'Context') -> str: return destination.state return self.default - def all_destinations(self) -> Iterable[str]: + def all_destinations(self) -> Collection[str]: """Returns all possible destination states.""" return [x.state for x in self.destinations] + [self.default] @@ -104,7 +104,7 @@ def next_state_for_label(self, label_context: Any) -> str: "Attempted to progress from a state with no next state", ) - def all_destinations(self) -> Iterable[str]: + def all_destinations(self) -> Collection[str]: """Returns no states.""" return [] From 331a351632f4f9da76a6ceeb2492bcebe17bdc14 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 25 Apr 2018 20:09:57 +0100 Subject: [PATCH 06/63] Add alternative using Cytoscape This renders in JS into a canvas --- routemaster/config/visualisation.html | 118 +++++++++++++++++++++ routemaster/server/endpoints.py | 13 ++- routemaster/state_machine/__init__.py | 6 +- routemaster/state_machine/visualisation.py | 27 +++++ 4 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 routemaster/config/visualisation.html diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html new file mode 100644 index 00000000..a9f9ff7e --- /dev/null +++ b/routemaster/config/visualisation.html @@ -0,0 +1,118 @@ + + + + + +Visualisation of state machine {{ state_machine_name }} + + + + + +
+ + + diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py index c0283597..7e06f0ad 100644 --- a/routemaster/server/endpoints.py +++ b/routemaster/server/endpoints.py @@ -1,5 +1,7 @@ """Core API endpoints for routemaster service.""" +import json + import sqlalchemy import pkg_resources from flask import Flask, Response, abort, jsonify, request @@ -10,6 +12,7 @@ UnknownLabel, LabelAlreadyExists, UnknownStateMachine, + convert_to_network, draw_state_machine, ) @@ -84,9 +87,15 @@ def view_state_machine(state_machine_name): msg = f"State machine '{state_machine_name}' does not exist" abort(404, msg) + template_html = pkg_resources.resource_string( + 'routemaster.config', + 'visualisation.html', + ).decode('utf-8') + + state_machine_json = json.dumps(convert_to_network(state_machine), indent=True) + return Response( - draw_state_machine(state_machine), - mimetype='image/svg+xml', + template_html.replace('{{ state_machine_config }}', state_machine_json), ) diff --git a/routemaster/state_machine/__init__.py b/routemaster/state_machine/__init__.py index 7ef38001..b9d469e6 100644 --- a/routemaster/state_machine/__init__.py +++ b/routemaster/state_machine/__init__.py @@ -23,7 +23,10 @@ LabelAlreadyExists, UnknownStateMachine, ) -from routemaster.state_machine.visualisation import draw_state_machine +from routemaster.state_machine.visualisation import ( + convert_to_network, + draw_state_machine, +) __all__ = ( 'LabelRef', @@ -37,6 +40,7 @@ 'process_action', 'get_label_state', 'labels_in_state', + 'convert_to_network', 'get_label_metadata', 'draw_state_machine', 'LabelAlreadyExists', diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py index dee5240c..39938cec 100644 --- a/routemaster/state_machine/visualisation.py +++ b/routemaster/state_machine/visualisation.py @@ -5,6 +5,33 @@ from routemaster.config import Action, StateMachine +def convert_to_network(state_machine: StateMachine) -> bytes: + """Produce an SVG drawing of a state machine.""" + graph = { + 'nodes': [], + 'edges': [], + } + + for state in state_machine.states: + node_kind = 'action' if isinstance(state, Action) else 'gate' + graph['nodes'].append({ + 'data': { + 'id': state.name, + 'name': state.name, + }, + 'classes': node_kind, + }) + + all_destinations = state.next_states.all_destinations() + for destination_name in all_destinations: + graph['edges'].append({'data': { + 'source': state.name, + 'target': destination_name, + }}) + + return graph + + def draw_state_machine(state_machine: StateMachine) -> bytes: """Produce an SVG drawing of a state machine.""" graph = pydot.Dot( From b51c976fb573246cb34e2eca899011163bc3f8f7 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 25 Apr 2018 20:11:24 +0100 Subject: [PATCH 07/63] Don't need to prettify this --- routemaster/server/endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py index 7e06f0ad..328d2940 100644 --- a/routemaster/server/endpoints.py +++ b/routemaster/server/endpoints.py @@ -92,7 +92,7 @@ def view_state_machine(state_machine_name): 'visualisation.html', ).decode('utf-8') - state_machine_json = json.dumps(convert_to_network(state_machine), indent=True) + state_machine_json = json.dumps(convert_to_network(state_machine)) return Response( template_html.replace('{{ state_machine_config }}', state_machine_json), From 28846c79648a22237a3765a8f1b5a0c62f8f31ec Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 25 Apr 2018 20:15:30 +0100 Subject: [PATCH 08/63] Drop draw_state_machine --- routemaster/server/endpoints.py | 1 - routemaster/state_machine/__init__.py | 2 -- .../state_machine/tests/test_visualisation.py | 6 ++-- routemaster/state_machine/visualisation.py | 28 ------------------- 4 files changed, 3 insertions(+), 34 deletions(-) diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py index 328d2940..fa496e2f 100644 --- a/routemaster/server/endpoints.py +++ b/routemaster/server/endpoints.py @@ -13,7 +13,6 @@ LabelAlreadyExists, UnknownStateMachine, convert_to_network, - draw_state_machine, ) server = Flask('routemaster') diff --git a/routemaster/state_machine/__init__.py b/routemaster/state_machine/__init__.py index b9d469e6..ac0eeae0 100644 --- a/routemaster/state_machine/__init__.py +++ b/routemaster/state_machine/__init__.py @@ -25,7 +25,6 @@ ) from routemaster.state_machine.visualisation import ( convert_to_network, - draw_state_machine, ) __all__ = ( @@ -42,7 +41,6 @@ 'labels_in_state', 'convert_to_network', 'get_label_metadata', - 'draw_state_machine', 'LabelAlreadyExists', 'LabelStateProcessor', 'UnknownStateMachine', diff --git a/routemaster/state_machine/tests/test_visualisation.py b/routemaster/state_machine/tests/test_visualisation.py index 80001e76..9d361394 100644 --- a/routemaster/state_machine/tests/test_visualisation.py +++ b/routemaster/state_machine/tests/test_visualisation.py @@ -1,5 +1,5 @@ -from routemaster.state_machine import draw_state_machine +from routemaster.state_machine import convert_to_network -def test_draw_state_machine(app_config): - draw_state_machine(app_config.config.state_machines['test_machine']) +def test_convert_to_network(app_config): + convert_to_network(app_config.config.state_machines['test_machine']) diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py index 39938cec..ed58247d 100644 --- a/routemaster/state_machine/visualisation.py +++ b/routemaster/state_machine/visualisation.py @@ -1,7 +1,5 @@ """Visualisation code for state machines.""" -import pydot - from routemaster.config import Action, StateMachine @@ -30,29 +28,3 @@ def convert_to_network(state_machine: StateMachine) -> bytes: }}) return graph - - -def draw_state_machine(state_machine: StateMachine) -> bytes: - """Produce an SVG drawing of a state machine.""" - graph = pydot.Dot( - graph_type='graph', - label=state_machine.name, - labelloc='t', - labeljust='l', - ) - - for state in state_machine.states: - node_colour = 'red' if isinstance(state, Action) else 'blue' - graph.add_node(pydot.Node(state.name, color=node_colour, shape='rect')) - - all_destinations = state.next_states.all_destinations() - for destination_name in all_destinations: - edge = pydot.Edge( - state.name, - destination_name, - dir='forward', - style='dashed' if len(all_destinations) > 1 else 'solid', - ) - graph.add_edge(edge) - - return graph.create(format='svg') From ba45628b7c29346fec76665a0fd909fa87753e0b Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 25 Apr 2018 20:16:40 +0100 Subject: [PATCH 09/63] Simplify the return type Cytoscape is apparently fine with this --- routemaster/state_machine/visualisation.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py index ed58247d..8998ee80 100644 --- a/routemaster/state_machine/visualisation.py +++ b/routemaster/state_machine/visualisation.py @@ -5,14 +5,11 @@ def convert_to_network(state_machine: StateMachine) -> bytes: """Produce an SVG drawing of a state machine.""" - graph = { - 'nodes': [], - 'edges': [], - } + elements = [] for state in state_machine.states: node_kind = 'action' if isinstance(state, Action) else 'gate' - graph['nodes'].append({ + elements.append({ 'data': { 'id': state.name, 'name': state.name, @@ -22,9 +19,9 @@ def convert_to_network(state_machine: StateMachine) -> bytes: all_destinations = state.next_states.all_destinations() for destination_name in all_destinations: - graph['edges'].append({'data': { + elements.append({'data': { 'source': state.name, 'target': destination_name, }}) - return graph + return elements From 70bfa8002dd1a0c91e9554d126fd4bbc3a5e63c0 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 25 Apr 2018 20:16:50 +0100 Subject: [PATCH 10/63] Type signature here --- routemaster/state_machine/visualisation.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py index 8998ee80..2f7a1117 100644 --- a/routemaster/state_machine/visualisation.py +++ b/routemaster/state_machine/visualisation.py @@ -2,8 +2,12 @@ from routemaster.config import Action, StateMachine +from typing import List, Dict, Union -def convert_to_network(state_machine: StateMachine) -> bytes: + +def convert_to_network( + state_machine: StateMachine, +) -> List[Dict[str, Union[Dict[str, str], str]]]: """Produce an SVG drawing of a state machine.""" elements = [] From a52bc9b8dfa3d080beafbd846a389c28ffd77f4f Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 25 Apr 2018 20:17:07 +0100 Subject: [PATCH 11/63] isort --- routemaster/state_machine/__init__.py | 4 +--- routemaster/state_machine/visualisation.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/routemaster/state_machine/__init__.py b/routemaster/state_machine/__init__.py index ac0eeae0..ed5873a5 100644 --- a/routemaster/state_machine/__init__.py +++ b/routemaster/state_machine/__init__.py @@ -23,9 +23,7 @@ LabelAlreadyExists, UnknownStateMachine, ) -from routemaster.state_machine.visualisation import ( - convert_to_network, -) +from routemaster.state_machine.visualisation import convert_to_network __all__ = ( 'LabelRef', diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py index 2f7a1117..7fef57a0 100644 --- a/routemaster/state_machine/visualisation.py +++ b/routemaster/state_machine/visualisation.py @@ -1,8 +1,8 @@ """Visualisation code for state machines.""" -from routemaster.config import Action, StateMachine +from typing import Dict, List, Union -from typing import List, Dict, Union +from routemaster.config import Action, StateMachine def convert_to_network( From ead96ab644dc0a2657cdd115021a7149651e1133 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 25 Apr 2018 20:19:48 +0100 Subject: [PATCH 12/63] Drop redundant inline style --- routemaster/config/visualisation.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index a9f9ff7e..d70b7694 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -32,7 +32,7 @@ var state_machine_config = JSON.parse(document.getElementById('state_machine_config').innerHTML); -
+
From 021f0c490a83bfc066e3c995ca1f6216509abd07 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 16:00:42 +0100 Subject: [PATCH 16/63] Template this properly This avoids needing to worry about escaping the content of our JSON. --- routemaster/config/visualisation.html | 2 +- routemaster/server/endpoints.py | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index c47fe146..c377a274 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -26,7 +26,7 @@
From 002882250fd488712e66061517ad6c311864220e Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 16:57:51 +0100 Subject: [PATCH 27/63] Drop default values --- routemaster/config/visualisation.html | 5 ----- 1 file changed, 5 deletions(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index 020b8e43..6e25774b 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -70,14 +70,9 @@ fit: true, // whether to fit the viewport to the graph directed: true, // whether the tree is directed downwards (or edges can point in any direction if false) padding: 30, // padding on fit - circle: false, // put depths in concentric circles if true, put depths top down if false spacingFactor: 0.55, // positive spacing factor, larger => more space between nodes (N.B. n/a if causes overlap) avoidOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space nodeDimensionsIncludeLabels: false, // Excludes the label when calculating node bounding boxes for the layout algorithm - roots: ['start'], // the roots of the trees - maximalAdjustments: 0, // how many times to try to position the nodes in a maximal way (i.e. no backtracking) - animate: false, // whether to transition the node positions - transform: function ( node, position ){ return position; } // transform a given node position. Useful for changing flow direction in discrete layouts } }); From 338ac6a5f74ca11f7481d9026e1fb66a6fa85ec3 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 16:57:59 +0100 Subject: [PATCH 28/63] Drop comments --- routemaster/config/visualisation.html | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index 6e25774b..6af1c7a6 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -67,12 +67,12 @@ layout: { name: 'breadthfirst', - fit: true, // whether to fit the viewport to the graph - directed: true, // whether the tree is directed downwards (or edges can point in any direction if false) - padding: 30, // padding on fit - spacingFactor: 0.55, // positive spacing factor, larger => more space between nodes (N.B. n/a if causes overlap) - avoidOverlap: true, // prevents node overlap, may overflow boundingBox if not enough space - nodeDimensionsIncludeLabels: false, // Excludes the label when calculating node bounding boxes for the layout algorithm + fit: true, + directed: true, + padding: 30, + spacingFactor: 0.55, + avoidOverlap: true, + nodeDimensionsIncludeLabels: false, } }); From 8c58dd1091de7ad56c3209a7d2ae371caa3c6f7b Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 16:58:20 +0100 Subject: [PATCH 29/63] Link to relevant docs --- routemaster/config/visualisation.html | 1 + 1 file changed, 1 insertion(+) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index 6af1c7a6..f4b474d8 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -66,6 +66,7 @@ elements: state_machine_config, layout: { + // http://js.cytoscape.org/#layouts/breadthfirst name: 'breadthfirst', fit: true, directed: true, From ce917ee490f1785f3860f5d9c1f91089b8e30b6b Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 17:24:45 +0100 Subject: [PATCH 30/63] Add a legend for the node types --- routemaster/config/visualisation.html | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index f4b474d8..ac59e213 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -3,6 +3,9 @@ Visualisation of state machine {{ state_machine_name }} @@ -18,8 +32,17 @@ var state_machine_config = {{ state_machine_config|tojson }}; +
+
action
+
gate
+
-
-
action
-
gate
+
+
+
action
+
gate
+
+
+ +
From f76ebcc6160ec256a7bdaf0c85e71c4db3049b8f Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 18:37:02 +0100 Subject: [PATCH 32/63] Calculate an initial spacing based on the number of nodes --- routemaster/config/visualisation.html | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index 686094a7..0a7923f6 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -83,9 +83,6 @@ document.getElementById('spacing-factor-display').innerHTML = precisionRound(layoutOptions.spacingFactor * 10, 1); } - document.getElementById('spacing-factor').value = layoutOptions.spacingFactor * 10; - displaySpacingFactor(); - var cy = cytoscape({ container: document.querySelector('#cy'), @@ -139,6 +136,10 @@ displaySpacingFactor(); cy.layout(layoutOptions).run(); } + + var initialSpacingFactor = 0.35 + cy.collection('node').length * 0.029; + document.getElementById('spacing-factor').value = initialSpacingFactor * 10; + changeSpacing(initialSpacingFactor); From 3230375afcfee4866701ada427fbb8f519b59640 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 18:40:13 +0100 Subject: [PATCH 33/63] Fade the control bar some more --- routemaster/config/visualisation.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index 0a7923f6..e2291cb9 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -15,7 +15,7 @@ top: 3.5em; } #controls { - background-color: #eee; + background-color: #f0f0f0; margin: 0; } #controls div { From 63ede8cb0d0a4ceb33d9fa84827341d8a0d71d1a Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 18:40:21 +0100 Subject: [PATCH 34/63] Add an instruction --- routemaster/config/visualisation.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index e2291cb9..a495c7f1 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -27,6 +27,9 @@ top: 0.5em; position: relative; } +.muted { + color: #666666; +} .action { background-color: orange; } @@ -55,6 +58,7 @@
+
Click and drag to move the graph
From 4849134315890e6c5928cf1cfa60bc21e1739da6 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 18:46:45 +0100 Subject: [PATCH 36/63] Remove unused imports --- routemaster/server/endpoints.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py index fa5fabee..e5de5139 100644 --- a/routemaster/server/endpoints.py +++ b/routemaster/server/endpoints.py @@ -1,12 +1,9 @@ """Core API endpoints for routemaster service.""" -import json - import sqlalchemy import pkg_resources from flask import ( Flask, - Response, abort, jsonify, request, From a8eb7591338c13123014c3cab1e2bc575b2d9ba8 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 18:47:30 +0100 Subject: [PATCH 37/63] isort --- routemaster/server/endpoints.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py index e5de5139..9e764121 100644 --- a/routemaster/server/endpoints.py +++ b/routemaster/server/endpoints.py @@ -2,13 +2,7 @@ import sqlalchemy import pkg_resources -from flask import ( - Flask, - abort, - jsonify, - request, - render_template_string, -) +from flask import Flask, abort, jsonify, request, render_template_string from routemaster import state_machine from routemaster.state_machine import ( From 956c59edbfe3a77c45fb02758740bc75ddebcad2 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 19:17:23 +0100 Subject: [PATCH 38/63] Add a zoom slider too While this can be achieved by scrolling, the slider offers a much more granular zoom. --- routemaster/config/visualisation.html | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index 1814b65e..60d194f6 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -23,7 +23,7 @@ padding: 0.5em; margin-right: 0.2em; } -#spacing-factor-input-container { +.range-input-container { top: 0.5em; position: relative; } @@ -52,12 +52,21 @@
+
+ +
Click and drag to move the graph
@@ -135,6 +144,16 @@ layout: layoutOptions }); + function changeZoom(value) { + cy.zoom({ + 'level': value, + 'position': { + 'x': window.innerWidth / 2, + 'y': window.innerHeight / 2 + } + }); + } + function changeSpacing(value) { layoutOptions.spacingFactor = value; displaySpacingFactor(); From 82a75aefd3785804ff6b2c5f93e906c5f3bb49de Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 19:22:35 +0100 Subject: [PATCH 39/63] Configure scroll zooming sensitivity instead of providing our own --- routemaster/config/visualisation.html | 22 ++-------------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/routemaster/config/visualisation.html b/routemaster/config/visualisation.html index 60d194f6..2f76dc91 100644 --- a/routemaster/config/visualisation.html +++ b/routemaster/config/visualisation.html @@ -58,16 +58,7 @@ -
- -
-
Click and drag to move the graph
+
Click and drag to move the graph; scroll to zoom
- +
action
From b0b05cb18203fb6675b6342e33e8a75f80604b7d Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 20:17:55 +0100 Subject: [PATCH 55/63] Subresource integrity the external library --- routemaster/config/visualisation.jinja | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/routemaster/config/visualisation.jinja b/routemaster/config/visualisation.jinja index c2902c77..8b0a6b62 100644 --- a/routemaster/config/visualisation.jinja +++ b/routemaster/config/visualisation.jinja @@ -44,7 +44,11 @@ - +
action
From 7d4edd2dec7f8c7b62622a8360ee226edd556ff0 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Wed, 2 May 2018 20:19:27 +0100 Subject: [PATCH 56/63] No need to return false here --- routemaster/config/visualisation.jinja | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routemaster/config/visualisation.jinja b/routemaster/config/visualisation.jinja index 8b0a6b62..bf952cdb 100644 --- a/routemaster/config/visualisation.jinja +++ b/routemaster/config/visualisation.jinja @@ -58,7 +58,7 @@ From d5c676dfe003529c513156ac4fea5c7ac79776c7 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 3 Apr 2022 10:42:05 +0100 Subject: [PATCH 57/63] Explicitly parse the config as JSON This clarifies that it's data not code. --- routemaster/config/visualisation.jinja | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/routemaster/config/visualisation.jinja b/routemaster/config/visualisation.jinja index bf952cdb..b8d9635f 100644 --- a/routemaster/config/visualisation.jinja +++ b/routemaster/config/visualisation.jinja @@ -41,8 +41,8 @@ Visualisation of state machine {{ state_machine_name }} - From 23689dcfb07f95f3e3aa2529344427d0df6c9bfe Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 3 Apr 2022 12:16:43 +0100 Subject: [PATCH 59/63] Fix lints --- routemaster/server/endpoints.py | 2 +- routemaster/tests/test_validation.py | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/routemaster/server/endpoints.py b/routemaster/server/endpoints.py index 60ad9014..0cc643ba 100644 --- a/routemaster/server/endpoints.py +++ b/routemaster/server/endpoints.py @@ -81,7 +81,7 @@ def view_state_machine(state_machine_name): try: state_machine = app.config.state_machines[state_machine_name] - except KeyError as k: + except KeyError: msg = f"State machine '{state_machine_name}' does not exist" abort(404, msg) diff --git a/routemaster/tests/test_validation.py b/routemaster/tests/test_validation.py index 8d156b68..27753530 100644 --- a/routemaster/tests/test_validation.py +++ b/routemaster/tests/test_validation.py @@ -1,5 +1,3 @@ -from pathlib import Path - import pytest import layer_loader From fd59e2f6cea64c7fab1f673955ef59c8fec5aeb8 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 3 Apr 2022 12:23:27 +0100 Subject: [PATCH 60/63] Update visualisation test for data loading changes on master branch --- routemaster/state_machine/tests/test_visualisation.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/routemaster/state_machine/tests/test_visualisation.py b/routemaster/state_machine/tests/test_visualisation.py index 59edfd42..a5dc3747 100644 --- a/routemaster/state_machine/tests/test_visualisation.py +++ b/routemaster/state_machine/tests/test_visualisation.py @@ -1,6 +1,6 @@ -import yaml +import layer_loader -from routemaster.config import load_config +from routemaster.config import yaml_load, load_config from routemaster.state_machine import nodes_for_cytoscape TEST_MACHINE_STATE_AS_NETWORK = [ @@ -80,7 +80,12 @@ def test_convert_example_to_network(app, repo_root): assert example_yaml.exists(), "Example file is missing! (is this test set up correctly?)" - example_config = load_config(yaml.load(example_yaml.read_text())) + example_config = load_config( + layer_loader.load_files( + [example_yaml], + loader=yaml_load, + ), + ) # quick check that we've loaded the config we expect assert list(example_config.state_machines.keys()) == ['user_lifecycle'] From e146e8a99f29d9de0367a441e23470144c2fa673 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 3 Apr 2022 12:28:16 +0100 Subject: [PATCH 61/63] Update cytoscape --- routemaster/config/visualisation.jinja | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/routemaster/config/visualisation.jinja b/routemaster/config/visualisation.jinja index 4e0099e2..468594b6 100644 --- a/routemaster/config/visualisation.jinja +++ b/routemaster/config/visualisation.jinja @@ -45,9 +45,10 @@ {{ state_machine_config|tojson }}
From f7170682db96b3cf3ce9623a673826e90f7b651d Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 3 Apr 2022 12:34:18 +0100 Subject: [PATCH 62/63] Adjust spacing presets to cope with larger workflows --- routemaster/config/visualisation.jinja | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routemaster/config/visualisation.jinja b/routemaster/config/visualisation.jinja index 468594b6..fba9a57c 100644 --- a/routemaster/config/visualisation.jinja +++ b/routemaster/config/visualisation.jinja @@ -59,7 +59,7 @@ @@ -157,7 +157,7 @@ cy.layout(layoutOptions).run(); } - var initialSpacingFactor = 0.35 + cy.nodes().length * 0.029; + var initialSpacingFactor = 0.4 + cy.nodes().length * 0.04; var spacingFactorElem = document.getElementById('spacing-factor'); spacingFactorElem.addEventListener('change', function() { changeSpacing(spacingFactorElem.value / 10); From 277c214342f8d8d3b45d3ae9e6a66214b000c944 Mon Sep 17 00:00:00 2001 From: Peter Law Date: Sun, 3 Apr 2022 13:23:12 +0100 Subject: [PATCH 63/63] Add labels for exit conditions to edges between nodes --- routemaster/config/model.py | 33 +++++++++++++++++++ routemaster/config/tests/test_next_states.py | 7 ++++ routemaster/config/visualisation.jinja | 7 +++- .../state_machine/tests/test_visualisation.py | 25 +++++--------- routemaster/state_machine/visualisation.py | 5 +-- 5 files changed, 58 insertions(+), 19 deletions(-) diff --git a/routemaster/config/model.py b/routemaster/config/model.py index 666a09e3..847a4b59 100644 --- a/routemaster/config/model.py +++ b/routemaster/config/model.py @@ -1,6 +1,7 @@ """Loading and validation of config files.""" import datetime +import collections from typing import ( TYPE_CHECKING, Any, @@ -103,6 +104,10 @@ def all_destinations(self) -> Collection[str]: """Returns the constant next state.""" return [self.state] + def destinations_for_render(self) -> Mapping[str, str]: + """Returns the constant next state.""" + return {self.state: ""} + class ContextNextStatesOption(NamedTuple): """Represents an option for a context conditional next state.""" @@ -128,6 +133,30 @@ def all_destinations(self) -> Collection[str]: """Returns all possible destination states.""" return [x.state for x in self.destinations] + [self.default] + def destinations_for_render(self) -> Mapping[str, str]: + """ + Returns destination states and a summary of how each might be reached. + + This is intended for use in visualisations, so while the description of + how to reach each state is somewhat Pythonic its focus is being + human-readable. + """ + destination_reasons = [ + (x.state, f"{self.path} == {x.value}") + for x in self.destinations + ] + [ + (self.default, "default"), + ] + + collected = collections.defaultdict(list) + for destination, raeson in destination_reasons: + collected[destination].append(raeson) + + return { + destination: " or ".join(reasons) + for destination, reasons in collected.items() + } + class NoNextStates(NamedTuple): """Represents the lack of a next state to progress to.""" @@ -142,6 +171,10 @@ def all_destinations(self) -> Collection[str]: """Returns no states.""" return [] + def destinations_for_render(self) -> Mapping[str, str]: + """Returns no states.""" + return {} + NextStates = Union[ConstantNextState, ContextNextStates, NoNextStates] diff --git a/routemaster/config/tests/test_next_states.py b/routemaster/config/tests/test_next_states.py index 7c2f9736..3875d3a3 100644 --- a/routemaster/config/tests/test_next_states.py +++ b/routemaster/config/tests/test_next_states.py @@ -11,12 +11,14 @@ def test_constant_next_state(): next_states = ConstantNextState(state='foo') assert next_states.all_destinations() == ['foo'] + assert next_states.destinations_for_render() == {'foo': ""} assert next_states.next_state_for_label(None) == 'foo' def test_no_next_states_must_not_be_called(): next_states = NoNextStates() assert next_states.all_destinations() == [] + assert next_states.destinations_for_render() == {} with pytest.raises(RuntimeError): next_states.next_state_for_label(None) @@ -34,6 +36,11 @@ def test_context_next_states(make_context): context = make_context(label='label1', metadata={'foo': True}) assert next_states.all_destinations() == ['1', '2', '3'] + assert next_states.destinations_for_render() == { + '1': "metadata.foo == True", + '2': "metadata.foo == False", + '3': "default", + } assert next_states.next_state_for_label(context) == '1' diff --git a/routemaster/config/visualisation.jinja b/routemaster/config/visualisation.jinja index fba9a57c..b4376f99 100644 --- a/routemaster/config/visualisation.jinja +++ b/routemaster/config/visualisation.jinja @@ -128,8 +128,13 @@ 'target-arrow-shape': 'triangle', 'target-arrow-color': '#ccc', 'line-color': '#ccc', + 'text-wrap': 'wrap', + 'text-max-width': 80, 'width': 1 }) + .style({ + 'label': 'data(label)', + }) .selector(':selected') .css({ 'background-color': 'black', @@ -157,7 +162,7 @@ cy.layout(layoutOptions).run(); } - var initialSpacingFactor = 0.4 + cy.nodes().length * 0.04; + var initialSpacingFactor = 0.5 + cy.nodes().length * 0.05; var spacingFactorElem = document.getElementById('spacing-factor'); spacingFactorElem.addEventListener('change', function() { changeSpacing(spacingFactorElem.value / 10); diff --git a/routemaster/state_machine/tests/test_visualisation.py b/routemaster/state_machine/tests/test_visualisation.py index a5dc3747..8e9a0d86 100644 --- a/routemaster/state_machine/tests/test_visualisation.py +++ b/routemaster/state_machine/tests/test_visualisation.py @@ -12,54 +12,47 @@ 'data': { 'source': 'start', 'target': 'perform_action', + 'label': 'feeds.tests.should_do_alternate_action == False or default', }, }, { 'data': { 'source': 'start', 'target': 'perform_alternate_action', + 'label': 'feeds.tests.should_do_alternate_action == True', }, }, - # We emit duplicate edges when the destination is duplicated; this seems to - # be fine though. { 'data': { - 'source': 'start', - 'target': 'perform_action', + 'id': 'perform_action', }, - }, - { - 'data': {'id': 'perform_action'}, 'classes': 'action', }, { 'data': { 'source': 'perform_action', 'target': 'end', + 'label': '', }, }, { - 'data': {'id': 'perform_alternate_action'}, + 'data': { + 'id': 'perform_alternate_action', + }, 'classes': 'action', }, { 'data': { 'source': 'perform_alternate_action', 'target': 'end', + 'label': 'feeds.tests.should_loop == False or default', }, }, - # We emit duplicate edges when the destination is duplicated; this seems to - # be fine though. { 'data': { 'source': 'perform_alternate_action', 'target': 'start', - }, - }, - { - 'data': { - 'source': 'perform_alternate_action', - 'target': 'end', + 'label': 'feeds.tests.should_loop == True', }, }, { diff --git a/routemaster/state_machine/visualisation.py b/routemaster/state_machine/visualisation.py index b696d625..3e199ec6 100644 --- a/routemaster/state_machine/visualisation.py +++ b/routemaster/state_machine/visualisation.py @@ -20,11 +20,12 @@ def nodes_for_cytoscape( 'classes': node_kind, }) - all_destinations = state.next_states.all_destinations() - for destination_name in all_destinations: + destinations = state.next_states.destinations_for_render() + for destination_name, reason in destinations.items(): elements.append({'data': { 'source': state.name, 'target': destination_name, + 'label': reason, }}) return elements