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

More easily extend web UI #1574

Merged
merged 10 commits into from
Oct 1, 2020
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/extending-locust.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ to the Flask app instance and use that to set up a new route::

You should now be able to start locust and browse to http://127.0.0.1:8089/added_page



Extending Web UI
================

As an alternative to adding simple web routes, you can use `Flask Blueprints
<https://flask.palletsprojects.com/en/1.1.x/blueprints/>`_ and `templates
<https://flask.palletsprojects.com/en/1.1.x/tutorial/templates/>`_ to not only add routes but also extend
the web UI to allow you to show custom data along side the built-in Locust stats. This is more advanced
as it involves also writing and including HTML and Javascript files to be served by routes but can
greatly enhance the utility and customizability of the web UI.

A working example of extending the web UI, complete with HTML and Javascript example files, can be found
in the `examples directory <https://github.com/locustio/locust/tree/master/examples>`_ of the Locust
source code.



Run a background greenlet
=========================

Expand Down
141 changes: 141 additions & 0 deletions examples/extend_web_ui/extend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-

"""
This is an example of a locustfile that uses Locust's built in event and web
UI extension hooks to track the sum of the content-length header in all
successful HTTP responses and display them in the web UI.
"""

import os
from time import time
from html import escape
from locust import HttpUser, TaskSet, task, web, between, events
from flask import Blueprint, render_template, jsonify, make_response


class MyTaskSet(TaskSet):
@task(2)
def index(l):
l.client.get("/")

@task(1)
def stats(l):
l.client.get("/stats/requests")


class WebsiteUser(HttpUser):
host = "http://127.0.0.1:8089"
wait_time = between(2, 5)
tasks = [MyTaskSet]


stats = {}
path = os.path.dirname(os.path.abspath(__file__))
extend = Blueprint(
"extend",
"extend_web_ui",
static_folder=f"{path}/static/",
static_url_path="/extend/static/",
template_folder=f"{path}/templates/",
)


@events.init.add_listener
def locust_init(environment, **kwargs):
"""
We need somewhere to store the stats.

On the master node stats will contain the aggregated sum of all content-lengths,
while on the worker nodes this will be the sum of the content-lengths since the
last stats report was sent to the master
"""
if environment.web_ui:
# this code is only run on the master node (the web_ui instance doesn't exist on workers)
@extend.route("/content-length")
def total_content_length():
"""
Add a route to the Locust web app where we can see the total content-length for each
endpoint Locust users are hitting. This is also used by the Content Length tab in the
extended web UI to show the stats. See `updateContentLengthStats()` and
`renderContentLengthTable()` in extend.js.
"""
report = {"stats": []}
if stats:
stats_tmp = []

for name, inner_stats in stats.items():
content_length = inner_stats["content-length"]

stats_tmp.append(
{"name": name, "safe_name": escape(name, quote=False), "content_length": content_length}
)

# Truncate the total number of stats and errors displayed since a large number of rows will cause the app
# to render extremely slowly.
report = {"stats": stats_tmp[:500]}
return jsonify(report)
return jsonify(stats)

@extend.route("/extend")
def extend_web_ui():
"""
Add route to access the extended web UI with our new tab.
"""
# ensure the template_args are up to date before using them
environment.web_ui.update_template_args()
return render_template("extend.html", **environment.web_ui.template_args)

@extend.route("/content-length/csv")
def request_content_length_csv():
"""
Add route to enable downloading of content-length stats as CSV
"""
response = make_response(content_length_csv())
file_name = "content_length{0}.csv".format(time())
disposition = "attachment;filename={0}".format(file_name)
response.headers["Content-type"] = "text/csv"
response.headers["Content-disposition"] = disposition
return response

def content_length_csv():
"""Returns the content-length stats as CSV."""
rows = [
",".join(
[
'"Name"',
'"Total content-length"',
]
)
]

if stats:
for url, inner_stats in stats.items():
rows.append(
'"%s",%.2f'
% (
url,
inner_stats["content-length"],
)
)
return "\n".join(rows)

# register our new routes and extended UI with the Locust web UI
environment.web_ui.app.register_blueprint(extend)


@events.request_success.add_listener
def on_request_success(request_type, name, response_time, response_length):
"""
Event handler that get triggered on every successful request
"""
stats.setdefault(name, {"content-length": 0})
stats[name]["content-length"] += response_length


@events.reset_stats.add_listener
def on_reset_stats():
"""
Event handler that get triggered on click of web UI Reset Stats button
"""
global stats
stats = {}
54 changes: 54 additions & 0 deletions examples/extend_web_ui/static/extend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
var contentLengthStats;
var contentLengthCharts = {};

// Used for sorting
var contentLengthDesc = false;
var contentLengthSortAttribute = "name";

// Trigger sorting of stats when a table label is clicked
$("#content-length .stats_label").click(function (event) {
event.preventDefault();
contentLengthSortAttribute = $(this).attr("data-sortkey");
contentLengthDesc = !contentLengthDesc;
renderContentLengthTable(window.content_length_report);
});

// Render and sort Content Length table
function renderContentLengthTable(content_length_report) {
$('#content-length tbody').empty();

window.alternate = false;
$('#content-length tbody').jqoteapp($('#content-length-template'), (content_length_report.stats).sort(sortBy(contentLengthSortAttribute, contentLengthDesc)));
}

// Get and repeatedly update Content Length stats and table
function updateContentLengthStats() {
$.get('./content-length', function (content_length_report) {
window.content_length_report = content_length_report
$('#content-length tbody').empty();

if (JSON.stringify(content_length_report) !== JSON.stringify({})) {
renderContentLengthTable(content_length_report);

// Make a separate chart for each URL
for (let index = 0; index < content_length_report.stats.length; index++) {
const url_stats = content_length_report.stats[index];

// If a chart already exists, just add the new value
if (contentLengthCharts.hasOwnProperty(url_stats.safe_name)) {
contentLengthCharts[url_stats.safe_name].addValue([url_stats.content_length]);
} else {
// If a chart doesn't already exist, create the chart first then add the value
contentLengthCharts[url_stats.safe_name] = new LocustLineChart($(".content-length-chart-container"), `Content Length for ${url_stats.safe_name}`, ["content-length"], "bytes");
// Add newly created chart to Locust web UI's array of charts
charts.push(contentLengthCharts[url_stats.safe_name])
contentLengthCharts[url_stats.safe_name].addValue([url_stats.content_length]);
}
}
}
// Schedule a repeat of updating stats in 2 seconds
setTimeout(updateContentLengthStats, 2000);
});
}

updateContentLengthStats();
48 changes: 48 additions & 0 deletions examples/extend_web_ui/templates/extend.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{% extends "index.html" %}

{% block extended_tabs %}
<li><a href="#" class="content-length-tab-link">Content Length</a></li>
{% endblock extended_tabs %}

{% block extended_panes %}
<div style="display:none;">
<table id="content-length" class="stats">
<thead>
<tr>
<th class="stats_label" href="#" data-sortkey="name">Name</th>
<th class="stats_label numeric" href="#" data-sortkey="content_length" title="Total content length">Total content length</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div id="content-length-container">
<div class="content-length-chart-container"></div>
<p class="note">Note: There is no persistence of these charts, if you refresh this page, new charts will be created.</p>
</div>
<p></p>
<a href="./content-length/csv">Download content length statistics CSV</a><br>
</div>
{% endblock extended_panes %}

{% block extended_script %}
<script type="text/javascript" src="./extend/static/extend.js"></script>
<script type="text/x-jqote-template" id="content-length-template">
<![CDATA[
<tr class="<%=(alternate ? "dark" : "")%> <%=(this.is_aggregated ? "total" : "")%>">
<td class="name" title="<%= this.name %>"><%= this.safe_name %></td>
<td class="numeric"><%= this.content_length %></td>
</tr>
<% alternate = !alternate; %>
]]>
</script>
<script type="text/x-jqote-template" id="content-length-template">
<![CDATA[
<tr class="<%=(alternate ? "dark" : "")%> <%=(this.is_aggregated ? "total" : "")%>">
<td class="name" title="<%= this.name %>"><%= this.safe_name %></td>
<td class="numeric"><%= this.content_length %></td>
</tr>
<% alternate = !alternate; %>
]]>
</script>
{% endblock extended_script %}
5 changes: 5 additions & 0 deletions locust/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ class Events:
is only fired on the master node and not on each worker node.
"""

reset_stats = EventHook
"""
Fired when the Reset Stats button is clicked in the web UI.
"""

def __init__(self):
for name, value in vars(type(self)).items():
if value == EventHook:
Expand Down
2 changes: 1 addition & 1 deletion locust/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ def timelimit_stop():
web_ui = None

# Fire locust init event which can be used by end-users' code to run setup code that
# need access to the Environment, Runner or WebUI
# need access to the Environment, Runner or WebUI.
environment.events.init.fire(environment=environment, runner=runner, web_ui=web_ui)

if options.headless:
Expand Down
2 changes: 1 addition & 1 deletion locust/static/chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
tooltip: {
trigger: 'axis',
formatter: function (params) {
if (!!params && params.length > 0 && !!params[0].value) {
if (!!params && params.length > 0 && params.some(param => !!param.value)) {
var str = params[0].name;
for (var i=0; i<params.length; i++) {
var param = params[i];
Expand Down
19 changes: 12 additions & 7 deletions locust/static/locust.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,19 @@ $(".close_link").click(function(event) {
$(this).parent().parent().hide();
});

$("ul.tabs").tabs("div.panes > div").on("onClick", function(event) {
if (event.target == $(".chart-tab-link")[0]) {
// trigger resizing of charts
rpsChart.resize();
responseTimeChart.resize();
usersChart.resize();
}
$("ul.tabs").tabs("div.panes > div").on("onClick", function (event) {
// trigger resizing of charts
resizeCharts();
});

var charts = []
function resizeCharts() {
solowalker27 marked this conversation as resolved.
Show resolved Hide resolved
for (let index = 0; index < charts.length; index++) {
const chart = charts[index];
chart.resize();
}
}

var stats_tpl = $('#stats-template');
var errors_tpl = $('#errors-template');
var exceptions_tpl = $('#exceptions-template');
Expand Down Expand Up @@ -162,6 +166,7 @@ $("#workers .stats_label").click(function(event) {
var rpsChart = new LocustLineChart($(".charts-container"), "Total Requests per Second", ["RPS", "Failures/s"], "reqs/s", ['#00ca5a', '#ff6d6d']);
var responseTimeChart = new LocustLineChart($(".charts-container"), "Response Times (ms)", ["Median Response Time", "95% percentile"], "ms");
var usersChart = new LocustLineChart($(".charts-container"), "Number of Users", ["Users"], "users");
charts.push(rpsChart, responseTimeChart, usersChart)

function updateStats() {
$.get('./stats/requests', function (report) {
Expand Down
5 changes: 3 additions & 2 deletions locust/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ a:hover {
border-radius: 3px;
border: 2px solid #11251c;
border-spacing: 0;
margin-bottom: 30px;
}
.status thead {
background: #11251c;
Expand Down Expand Up @@ -357,14 +358,14 @@ a:hover {
#charts {
width: 100%;
}
#charts .chart {
.chart {
height: 350px;
margin-bottom: 30px;
box-sizing: border-box;
border: 1px solid #11271e;
border-radius: 3px;
}
#charts .note {
.note {
color: #b3c3bc;
margin-bottom: 30px;
border-radius: 3px;
Expand Down
8 changes: 8 additions & 0 deletions locust/templates/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ <h2>Edit running load test</h2>
{% if is_distributed %}
<li><a href="#">Workers</a></li>
{% endif %}
{% block extended_tabs %}
{% endblock extended_tabs %}
</ul>
</nav>
<div class="panes container">
Expand Down Expand Up @@ -197,6 +199,7 @@ <h2>Edit running load test</h2>
<a href="./stats/report" target="_blank">Download Report</a><br>
</div>
</div>
{% if is_distributed %}
<div style="display:none;">
<table id="workers">
<thead>
Expand All @@ -211,6 +214,9 @@ <h2>Edit running load test</h2>
</tbody>
</table>
</div>
{% endif %}
{% block extended_panes %}
{% endblock extended_panes %}
</div>
</div>

Expand Down Expand Up @@ -314,5 +320,7 @@ <h2>Version <a href="https://github.com/locustio/locust/releases/tag/{{version}}
</script>
<script type="text/javascript" src="./static/chart.js?v={{ version }}"></script>
<script type="text/javascript" src="./static/locust.js?v={{ version }}"></script>
{% block extended_script %}
{% endblock extended_script %}
</body>
</html>
Loading