Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
04722ad
First pass at state machite visualisation
danpalmer Dec 27, 2017
5929e3c
Merge branch 'master' into visualisation
danpalmer Dec 27, 2017
9f15757
Improve styling
danpalmer Dec 27, 2017
5d1f35e
Merge branch 'master' into visualisation
PeterJCLaw Jan 22, 2018
293b056
Merge branch 'master' into visualisation
PeterJCLaw Apr 18, 2018
83ee7ac
Clarify that this is a collection
PeterJCLaw Apr 18, 2018
698f2f8
Expect this endpoint to exist too
PeterJCLaw Apr 18, 2018
9a531b4
These are collections too
PeterJCLaw Apr 18, 2018
2767516
Merge branch 'master' into visualisation
PeterJCLaw Apr 25, 2018
331a351
Add alternative using Cytoscape
PeterJCLaw Apr 25, 2018
b51c976
Don't need to prettify this
PeterJCLaw Apr 25, 2018
28846c7
Drop draw_state_machine
PeterJCLaw Apr 25, 2018
ba45628
Simplify the return type
PeterJCLaw Apr 25, 2018
70bfa80
Type signature here
PeterJCLaw Apr 25, 2018
a52bc9b
isort
PeterJCLaw Apr 25, 2018
ead96ab
Drop redundant inline style
PeterJCLaw Apr 25, 2018
7c83fc8
Fix indentation
PeterJCLaw Apr 25, 2018
a712785
Slightly better colours
PeterJCLaw Apr 25, 2018
345837a
Drop more dead code
PeterJCLaw Apr 25, 2018
16e0b18
Merge branch 'master' into visualisation
PeterJCLaw May 2, 2018
021f0c4
Template this properly
PeterJCLaw May 2, 2018
2c48ba1
Actually template in the state machine name
PeterJCLaw May 2, 2018
0335279
Add a smoke test for the visualisation endpoint
PeterJCLaw May 2, 2018
284a79f
Explicitly annotate this
PeterJCLaw May 2, 2018
2ab8943
We don't need pydot now that we're using cytoscape
PeterJCLaw May 2, 2018
ed5f4dd
No need to duplicate the id as the name
PeterJCLaw May 2, 2018
13cb98f
Make this test pass
PeterJCLaw May 2, 2018
8722e5b
Expand these tests to check the result
PeterJCLaw May 2, 2018
ce5a5da
Rename for clarity
PeterJCLaw May 2, 2018
f09da3f
Drop unused styling
PeterJCLaw May 2, 2018
5a53abc
Just drop this inline; no need to parse it separately
PeterJCLaw May 2, 2018
0028822
Drop default values
PeterJCLaw May 2, 2018
338ac6a
Drop comments
PeterJCLaw May 2, 2018
8c58dd1
Link to relevant docs
PeterJCLaw May 2, 2018
ce917ee
Add a legend for the node types
PeterJCLaw May 2, 2018
5520840
Let the user choose the spacing
PeterJCLaw May 2, 2018
f76ebcc
Calculate an initial spacing based on the number of nodes
PeterJCLaw May 2, 2018
3230375
Fade the control bar some more
PeterJCLaw May 2, 2018
63ede8c
Add an instruction
PeterJCLaw May 2, 2018
6a2a22a
Simplify this lookup
PeterJCLaw May 2, 2018
4849134
Remove unused imports
PeterJCLaw May 2, 2018
a8eb759
isort
PeterJCLaw May 2, 2018
956c59e
Add a zoom slider too
PeterJCLaw May 2, 2018
82a75ae
Configure scroll zooming sensitivity instead of providing our own
PeterJCLaw May 2, 2018
4dca5d4
Allow an adjustment once layed out
PeterJCLaw May 2, 2018
aaf1ec8
Fit to the display window
PeterJCLaw May 2, 2018
92416b9
Also test the 404 case
PeterJCLaw May 2, 2018
a2f3e85
Extract fixture for the root path of the repo
PeterJCLaw May 2, 2018
11a9e81
Rename for clarity
PeterJCLaw May 2, 2018
1171013
Use system fonts
PeterJCLaw May 2, 2018
00a0eb3
Indentation
PeterJCLaw May 2, 2018
f98ad5f
Rename file to clarify rendering
PeterJCLaw May 2, 2018
fd2c457
Include the template in the manifest
PeterJCLaw May 2, 2018
db7f349
Simplify this
PeterJCLaw May 2, 2018
f891b93
Not SVG
PeterJCLaw May 2, 2018
dcbc51f
Assert the content type
PeterJCLaw May 2, 2018
4b9d8c0
Name this type for clarity when used
PeterJCLaw May 2, 2018
8c07681
This isn't just HTML any more
PeterJCLaw May 2, 2018
4b5d3a7
Switch to the minified version of the library
PeterJCLaw May 2, 2018
b0b05cb
Subresource integrity the external library
PeterJCLaw May 2, 2018
7d4edd2
No need to return false here
PeterJCLaw May 2, 2018
d5c676d
Explicitly parse the config as JSON
PeterJCLaw Apr 3, 2022
b24c25d
Merge branch 'master' into visualisation
PeterJCLaw Apr 3, 2022
0e9e49c
Connect event up in JS rather than inline
PeterJCLaw Apr 3, 2022
23689dc
Fix lints
PeterJCLaw Apr 3, 2022
fd59e2f
Update visualisation test for data loading changes on master branch
PeterJCLaw Apr 3, 2022
e146e8a
Update cytoscape
PeterJCLaw Apr 3, 2022
f717068
Adjust spacing presets to cope with larger workflows
PeterJCLaw Apr 3, 2022
277c214
Add labels for exit conditions to edges between nodes
PeterJCLaw Apr 3, 2022
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 MANIFEST.in
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
include version.py
include routemaster/config/schema.yaml
include routemaster/config/visualisation.jinja
40 changes: 37 additions & 3 deletions routemaster/config/model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Loading and validation of config files."""

import datetime
import collections
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -11,6 +12,7 @@
Pattern,
Iterable,
Sequence,
Collection,
NamedTuple,
)
from dataclasses import dataclass
Expand Down Expand Up @@ -98,10 +100,14 @@ 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]

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."""
Expand All @@ -123,10 +129,34 @@ 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]

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."""
Expand All @@ -137,10 +167,14 @@ 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 []

def destinations_for_render(self) -> Mapping[str, str]:
"""Returns no states."""
return {}


NextStates = Union[ConstantNextState, ContextNextStates, NoNextStates]

Expand Down
4 changes: 1 addition & 3 deletions routemaster/config/tests/test_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import re
import datetime
import contextlib
from pathlib import Path
from unittest import mock

import pytest
Expand Down Expand Up @@ -415,15 +414,14 @@ def test_multiple_feeds_same_name_invalid():
load_config(yaml_data('multiple_feeds_same_name_invalid'))


def test_example_config_loads():
def test_example_config_loads(repo_root):
"""
Test that the example.yaml in this repo can be loaded.

This ensures that the example file itself is valid and is not intended as a
test of the system.
"""

repo_root = Path(__file__).parent.parent.parent.parent
example_yaml = repo_root / 'example.yaml'

assert example_yaml.exists(), "Example file is missing! (is this test set up correctly?)"
Expand Down
7 changes: 7 additions & 0 deletions routemaster/config/tests/test_next_states.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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'


Expand Down
174 changes: 174 additions & 0 deletions routemaster/config/visualisation.jinja
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-size: 14px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
margin: 0;
}
#graph-container {
height: 100%;
width: 100%;
position: absolute;
left: 0;
top: 3.5em;
}
#controls {
background-color: #f0f0f0;
margin: 0;
}
#controls div {
display: inline-block;
padding: 0.5em;
margin-right: 0.2em;
}
.range-input-container {
top: 0.5em;
position: relative;
}
.muted {
color: #666666;
}
.action {
background-color: orange;
}
.gate {
background-color: lightBlue;
}
</style>
<title>Visualisation of state machine {{ state_machine_name }}</title>
</head>
<body>
<script type="application/json" id="state-machine-config">
{{ state_machine_config|tojson }}
</script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/cytoscape/3.21.0/cytoscape.min.js"
integrity="sha512-6NNH8uUbdENB10r5FH/4DZviHTQ44GtZ+c+fwSiWM4GekjajHkYanpH0+jWt3meFll/HHWAcxKLcMpSAXuT/Dg=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<div id="controls">
<div id="legend">
<div class="action">action</div>
<div class="gate">gate</div>
</div>
<div>
<label>
Spacing:
<span class="range-input-container">
<input id="spacing-factor" type="range" min="1" max="50" step="0.1">
</span>
<span id="spacing-factor-display"></span>
</label>
</div>
<div class="muted">Click and drag to move the graph; scroll to zoom</div>
</div>
<div id="graph-container"></div>
<script>
function backgroundColourForNodeClass(nodeClass) {
var style = getComputedStyle(document.querySelector('#legend ' + nodeClass));
return style.backgroundColor;
}

var layoutOptions = {
// http://js.cytoscape.org/#layouts/breadthfirst
name: 'breadthfirst',
fit: true,
directed: true,
padding: 30,
spacingFactor: 1.35,
avoidOverlap: false,
nodeDimensionsIncludeLabels: false,
maximalAdjustments: 1,
};

function precisionRound(number, precision) {
var factor = Math.pow(10, precision);
return Math.round(number * factor) / factor;
}

function displaySpacingFactor() {
document.getElementById('spacing-factor-display').innerHTML = precisionRound(layoutOptions.spacingFactor * 10, 1);
}

function parseConfig(elementId) {
return JSON.parse(document.getElementById(elementId).innerHTML);
}

var cy = cytoscape({
container: document.getElementById('graph-container'),

boxSelectionEnabled: false,
autounselectify: true,
wheelSensitivity: 0.1,

style: cytoscape.stylesheet()
.selector()
.css({
'font-family': getComputedStyle(document.body).fontFamily,
})
.selector('node')
.css({
'content': 'data(id)',
'text-valign': 'center',
'color': 'black',
})
.style({
'shape': 'square',
'width': 'label',
'height': 'label',
'padding': '1em',
})
.selector('edge')
.css({
'curve-style': 'bezier',
'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',
'line-color': 'black',
'target-arrow-color': 'black',
'source-arrow-color': 'black'
})
.selector('.action')
.css({
'background-color': backgroundColourForNodeClass('.action'),
})
.selector('.gate')
.css({
'background-color': backgroundColourForNodeClass('.gate'),
}),

elements: parseConfig('state-machine-config'),

layout: layoutOptions
});

function changeSpacing(value) {
layoutOptions.spacingFactor = value;
displaySpacingFactor();
cy.layout(layoutOptions).run();
}

var initialSpacingFactor = 0.5 + cy.nodes().length * 0.05;
var spacingFactorElem = document.getElementById('spacing-factor');
spacingFactorElem.addEventListener('change', function() {
changeSpacing(spacingFactorElem.value / 10);
})
spacingFactorElem.value = initialSpacingFactor * 10;
changeSpacing(initialSpacingFactor);
</script>
</body>
</html>
7 changes: 7 additions & 0 deletions routemaster/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import contextlib
import subprocess
from typing import Any, Dict
from pathlib import Path
from unittest import mock

import pytest
Expand Down Expand Up @@ -499,6 +500,12 @@ def version():
return 'development'


@pytest.fixture()
def repo_root():
"""Return root path of the repo."""
return Path(__file__).parent.parent


@pytest.fixture()
def current_state(app):
"""Get the current state of a label."""
Expand Down
33 changes: 32 additions & 1 deletion routemaster/server/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import sqlalchemy
import pkg_resources
from flask import Flask, abort, jsonify, request
from flask import Flask, abort, jsonify, request, render_template_string

from routemaster import state_machine
from routemaster.state_machine import (
LabelRef,
UnknownLabel,
LabelAlreadyExists,
UnknownStateMachine,
nodes_for_cytoscape,
)

server = Flask('routemaster')
Expand Down Expand Up @@ -59,13 +60,43 @@ 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()
],
})


@server.route('/state-machines/<state_machine_name>/view', methods=['GET'])
def view_state_machine(state_machine_name):
"""
Render an image of a state machine.

Returns:
- 200 Ok, HTML: 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:
msg = f"State machine '{state_machine_name}' does not exist"
abort(404, msg)

template_string = pkg_resources.resource_string(
'routemaster.config',
'visualisation.jinja',
).decode('utf-8')

return render_template_string(
template_string,
state_machine_name=state_machine_name,
state_machine_config=nodes_for_cytoscape(state_machine),
)


@server.route(
'/state-machines/<state_machine_name>/labels',
methods=['GET'],
Expand Down
Loading