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

UI Overhaul #157

Merged
merged 14 commits into from
May 14, 2019
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
+ Links to the Swagger/ReDoc API documentation are now provided on the main
page. (#152)
+ Fixed small error in development documentation. (#153)
+ Plots are now all shown on a single page, thus making it much easier to
navigate between plots. (#58)
+ Added Bootstrap4
+ updated the jstree data structure to use internal ID instead of a
generated URL (#156)


## 0.6.0b1 (2019-05-01)
Expand Down
64 changes: 39 additions & 25 deletions src/trendlines/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,35 +54,35 @@ class Meta:
model = orm.DataPoint


@pages.route('/', methods=['GET'])
def index():
@pages.route("/", methods=['GET'])
@pages.route("/plot/<metric>", methods=["GET"])
def index(metric=None):
"""
Main page.

Displays a list of all the known metrics with links.
"""
raw_data = db.get_metrics()
data = utils.build_jstree_data(raw_data)
return render_template("trendlines/index.html", data=data)

Also allows direct links to a specific plot.

@pages.route("/plot/<metric>", methods=["GET"])
def plot(metric=None):
"""
Plot a given metric.
Parameters
----------
metric : str or int, optional
The metric_id or metric name to plot.
"""
if metric is None:
return "Need a metric, friend!"
metric_name = None
if metric is not None:
# Support both metric_id and metric_name
try:
metric_id = int(metric)
metric_name = orm.Metric.get(orm.Metric.metric_id == metric_id).name
except ValueError:
# We couldn't parse as an int, so it's a metric name instead.
metric_name = metric

data = db.get_data(metric)
units = db.get_units(metric)
data = utils.format_data(data, units)
if len(data) == 0:
logger.warning("No data exists for metric '%s'" % metric)
return "Metric '{}' wasn't found. No data, maybe?".format(metric)
metric_list = db.get_metrics()
tree_data = utils.build_jstree_data(metric_list)

# TODO: Ajax request for this data instead of sending it to the template.
return render_template('trendlines/plot.html', name=metric, data=data)
return render_template('trendlines/index.html',
tree_data=tree_data,
metric_id=metric_name)


@api.route("/api/v1/data")
Expand Down Expand Up @@ -117,13 +117,27 @@ def post(self):
return msg, 201


@api.route("/api/v1/data/<metric_name>")
@api.route("/api/v1/data/<metric>")
class DataByName(MethodView):
def get(self, metric_name):
def get(self, metric):
"""
Return data for a given metric as JSON.

Parameters
----------
metric : str or int
The metric name or the metric internal id (int) to get data for.
"""
logger.debug("API: get '%s'" % metric_name)
logger.debug("GET /api/v1/data/%s" % metric)

# Support both metric_id and metric_name
try:
metric_id = int(metric)
metric_name = orm.Metric.get(orm.Metric.metric_id == metric_id).name
except ValueError:
# We couldn't parse as an int, so it's a metric name instead.
metric_name = metric

try:
raw_data = db.get_data(metric_name)
units = db.get_units(metric_name)
Expand Down
78 changes: 63 additions & 15 deletions src/trendlines/static/core.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
/**
* Populate the JSTree tree.
*/
function populateTree(data) {
function populateTree(data, metricId) {
var tree = $('#jstree-div');

// Create an instance when the DOM is ready.
tree.jstree(
{
Expand All @@ -13,37 +14,84 @@ function populateTree(data) {
);

// Bind events.

// This event allows us to select a node via `metricId` argument. When the
// tree is done populating, the `selectNodeById` function is fired. We need to
// wait for the the tree to be fully ready or else the `select_node` method
// will fail.
tree.on("ready.jstree", function(e, data){
console.log("tree ready");
selectNodeById(tree, metricId);
});

// Really just here in case it's needed
tree.on("changed.jstree", function (e, data) {
});

// Open all of the nodes by default
tree.on("loaded.jstree", function () {
tree.jstree('open_all');
});

// Go to data pages if they exist, otherwise just open the tree.
// Update the plot or toggle the node open/closed.
tree.on('select_node.jstree', function(e, data) {
// jsTree puts the original data structure in a nested object
// called 'original'. How original of them. Hahaha I crack myself up.
// If `url` is defined, take us there.
if (data.node.original.metric_id !== null) {
var expected = "/plot/" + data.node.original.id;
var new_href = document.location.origin + expected;

// Take the user to the plot page.
document.location.href = new_href;
} else {
data.instance.toggle_node(data.node);
};
treeChanged(e, data);
});
};


/*
* Update the plot if data exists, otherwise just open the tree.
* This is called when the `select_node` event is seen.
*/
function treeChanged(e, data) {
// If `metric_id` is defined, then we can query data
if (data.node.original.metric_id !== null) {
var expected = "/api/v1/data/" + data.node.original.metric_id;
// grab the plot data from the api
$.getJSON(expected)
.done(function(jsonData) {
makePlot(jsonData);

// This updates the URL to reflect which plot is shown.
var history_url = "/plot/" + data.node.original.metric_id;
window.history.pushState('page2', 'Title', history_url);
})
.fail(function(jqXHR, textStatus, errorThrown) {
console.log("Request failed: " + errorThrown);
});
} else {
// Otherwise just open/close the tree node.
data.instance.toggle_node(data.node);
};
}


/*
* Select a specific tree element.
* Called when both:
* (a) a metric_id is given in the URL and
* (b) the jsTree object has fully loaded.
*/
function selectNodeById(tree, metricId) {
if (typeof metricId !== 'undefined') {
// We were given a metric ID, so let's select it in the jstree
tree.jstree('select_node', metricId);
}

}


/**
* Make the plotly Plot
*/
function makePlot(data) {
TESTER = document.getElementById('graph');

// Clear the plot before doing anything. Failure to do so results in each
// trace being appended.
Plotly.purge(TESTER);

// I think Plotly only accepts 1D arrays of data, so split things out.
var x = data.rows.map(function (obj) {return obj.timestamp});
var y = data.rows.map(function (obj) {return obj.value});
Expand All @@ -63,7 +111,7 @@ function makePlot(data) {
};

trace = [ trace1 ];
Plotly.plot(TESTER, trace, layout);
Plotly.plot(TESTER, trace, layout, {responsive: true});

$(document).ready(
function() {
Expand Down
52 changes: 46 additions & 6 deletions src/trendlines/templates/trendlines/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,53 @@

{% block body %}

<div id="jstree-div">
</div>
<div id="content" class="container">
<div class="row">
<div class="col">
<h1><a href="{{ url_for('pages.index') }}">Trendlines</a></h1>
<h4>v{{ version }}</h4>
<div id="apiLinks">
<ul>
<li><a target="_blank" href="/api/">API Reference (Swagger)</a></li>
<li><a target="_blank" href="/api/redoc">API Reference (ReDoc)</a></li>
</ul>
</div>
</div>
</div>

<div class="row">
<div id="tree" class="col-sm-3 order-1 border border-primary" style="height: 500px; overflow-y: scroll;">
<!-- This div holds the left side: primarily the JS tree -->
<div id="jstree-div">
</div>

<script>
$(document).ready( function() {
var treeData = {{ tree_data | tojson | safe }};

// Populate the jsTree with the metric names.
var metricId = {{ metric_id | tojson | safe }};
populateTree(treeData, metricId);
});
</script>
</div>

<script>
var data = {{ data | tojson | safe }};
$(populateTree(data));
</script>
<div id="data" class="col-sm-9 order-2 border">
<!-- The right side: the plot and buttons -->
<div id="change-axis-buttons" class="btn-group btn-group-toggle" data-toggle="buttons">
<label class="btn btn-primary active">
<input id="x-axis-type-sequential" type="radio" name="x-axis-type" autocomplete="off" value="sequential" checked>Sequential
</label>
<label class="btn btn-primary">
<input id="x-axis-type-time" type="radio" name="x-axis-type" autocomplete="off" value="time">Time Series
</label>
</div>

<div id="graph" style="height: 500px;"></div>

</div>
</div>
</div>

{% endblock %}

Expand Down
18 changes: 5 additions & 13 deletions src/trendlines/templates/trendlines/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
<script type="text/javascript" charset="utf-8" src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script type="text/javascript" charset="utf-8" src="https://cdn.plot.ly/plotly-1.43.0.min.js"></script>
<script type="text/javascript" charset="utf-8" src="https://cdnjs.cloudflare.com/ajax/libs/jstree/3.3.7/jstree.min.js"></script>
<script type="text/javascript" charset="utf-8" src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script type="text/javascript" charset="utf-8" src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
<script type="text/javascript" charset="utf-8" src="{{ url_for('static', filename='core.js') }}"></script>

<!-- CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jstree/3.3.7/themes/default/style.min.css" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous" />

<title>Trendlines</title>

Expand All @@ -22,19 +25,8 @@
</header>

<main>
<h1><a href="{{ url_for('pages.index') }}">Trendlines</a></h1>
<h4>v{{ version }}</h4>
<div id="apiLinks">
<ul>
<li><a target="_blank" href="/api/">API Reference (Swagger)</a></li>
<li><a target="_blank" href="/api/redoc">API Reference (ReDoc)</a></li>
</ul>
</div>

<div id="content">
{% block body %}
{% endblock %}
</div>
{% block body %}
{% endblock %}
</main>

<footer>
Expand Down
25 changes: 0 additions & 25 deletions src/trendlines/templates/trendlines/plot.html

This file was deleted.

33 changes: 28 additions & 5 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,17 +56,28 @@ def test_index_with_data(client, populated_db):
assert b"foo.bar" in rv.data


@pytest.mark.xfail(reason="Needs code updates")
def test_plot(client):
@pytest.mark.xfail(
reason="JS callbacks are async: plotly div not populated immediatly"
)
def test_plot_with_data_by_name(client, populated_db):
rv = client.get("/plot/foo")
assert rv.status_code == 404
assert rv.status_code == 200
assert b"foo" in rv.data
assert b'<div id="graph"' in rv.data
# TODO: make this work. Issue is JS callbacks.
# assert b'<div class="plot-container plotly">' in rv.data


def test_plot_with_data(client, populated_db):
rv = client.get("/plot/foo")
@pytest.mark.xfail(
reason="JS callbacks are async: plotly div not populated immediatly"
)
def test_plot_with_data_by_id(client, populated_db):
rv = client.get("/plot/1")
assert rv.status_code == 200
assert b"foo" in rv.data
assert b'<div id="graph"' in rv.data
# TODO: make this work. Issue is JS callbacks.
# assert b'<div class="plot-container plotly">' in rv.data


def test_api_add(client):
Expand Down Expand Up @@ -95,6 +106,18 @@ def test_api_get_data_as_json(client, populated_db):
assert d[3]['value'] == 9


def test_api_get_data_by_id(client, populated_db):
rv = client.get("/api/v1/data/2")
assert rv.status_code == 200
assert rv.is_json
d = rv.get_json()
assert d['units'] == None
d = d['rows']
assert d[0]['n'] == 0
assert d[0]['value'] == 15
assert d[3]['value'] == 9


def test_api_get_data_as_json_metric_not_found(client):
rv = client.get("/api/v1/data/missing")
assert rv.status_code == 404
Expand Down