Skip to content

Commit

Permalink
Merge pull request #2420 from andrewbaldwin44/feature/2419
Browse files Browse the repository at this point in the history
[Feature/2419] Modernize the report.html when using the --modern-ui Flag
  • Loading branch information
cyberw authored Oct 11, 2023
2 parents 6ecccb8 + 68203e2 commit 711e9fd
Show file tree
Hide file tree
Showing 27 changed files with 683 additions and 335 deletions.
115 changes: 82 additions & 33 deletions locust/html.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from jinja2 import Environment, FileSystemLoader
import os
import glob
import pathlib
import datetime
from itertools import chain
Expand All @@ -9,6 +10,10 @@
from html import escape
from json import dumps
from .runners import MasterRunner, STATE_STOPPED, STATE_STOPPING
from flask import render_template as flask_render_template


PERCENTILES_FOR_HTML_REPORT = [0.50, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1.0]


def render_template(file, **kwargs):
Expand All @@ -18,7 +23,13 @@ def render_template(file, **kwargs):
return template.render(**kwargs)


def get_html_report(environment, show_download_link=True):
def get_html_report(
environment,
static_path=os.path.join(os.path.dirname(__file__), "static"),
show_download_link=True,
use_modern_ui=False,
theme="",
):
stats = environment.runner.stats

start_ts = stats.start_time
Expand Down Expand Up @@ -47,22 +58,27 @@ def get_html_report(environment, show_download_link=True):
history = stats.history

static_js = []
js_files = ["jquery-1.11.3.min.js", "echarts.common.min.js", "vintage.js", "chart.js", "tasks.js"]
if use_modern_ui:
js_files = [os.path.basename(filepath) for filepath in glob.glob(os.path.join(static_path, "*.js"))]
else:
js_files = ["jquery-1.11.3.min.js", "echarts.common.min.js", "vintage.js", "chart.js", "tasks.js"]

for js_file in js_files:
path = os.path.join(os.path.dirname(__file__), "static", js_file)
static_js.append("// " + js_file)
path = os.path.join(static_path, js_file)
static_js.append("// " + js_file + "\n")
with open(path, encoding="utf8") as f:
static_js.append(f.read())
static_js.extend(["", ""])

static_css = []
css_files = ["tables.css"]
for css_file in css_files:
path = os.path.join(os.path.dirname(__file__), "static", "css", css_file)
static_css.append("/* " + css_file + " */")
with open(path, encoding="utf8") as f:
static_css.append(f.read())
static_css.extend(["", ""])
if not use_modern_ui:
static_css = []
css_files = ["tables.css"]
for css_file in css_files:
path = os.path.join(static_path, "css", css_file)
static_css.append("/* " + css_file + " */")
with open(path, encoding="utf8") as f:
static_css.append(f.read())
static_css.extend(["", ""])

is_distributed = isinstance(environment.runner, MasterRunner)
user_spawned = (
Expand All @@ -77,26 +93,59 @@ def get_html_report(environment, show_download_link=True):
"total": get_ratio(environment.user_classes, user_spawned, True),
}

res = render_template(
"report.html",
int=int,
round=round,
escape=escape,
str=str,
requests_statistics=requests_statistics,
failures_statistics=failures_statistics,
exceptions_statistics=exceptions_statistics,
start_time=start_time,
end_time=end_time,
host=host,
history=history,
static_js="\n".join(static_js),
static_css="\n".join(static_css),
show_download_link=show_download_link,
locustfile=environment.locustfile,
tasks=dumps(task_data),
percentile1=stats_module.PERCENTILES_TO_CHART[0],
percentile2=stats_module.PERCENTILES_TO_CHART[1],
)
if use_modern_ui:
res = flask_render_template(
"report.html",
template_args={
"is_report": True,
"requests_statistics": [stat.to_dict(escape_string_values=True) for stat in requests_statistics],
"failures_statistics": [stat.to_dict() for stat in failures_statistics],
"exceptions_statistics": [stat for stat in exceptions_statistics],
"response_time_statistics": [
{
"name": escape(stat.name),
"method": escape(stat.method or ""),
**{
str(percentile): stat.get_response_time_percentile(percentile)
for percentile in PERCENTILES_FOR_HTML_REPORT
},
}
for stat in requests_statistics
],
"start_time": start_time,
"end_time": end_time,
"host": escape(str(host)),
"history": history,
"show_download_link": show_download_link,
"locustfile": escape(str(environment.locustfile)),
"tasks": task_data,
"percentile1": stats_module.PERCENTILES_TO_CHART[0],
"percentile2": stats_module.PERCENTILES_TO_CHART[1],
},
theme=theme,
static_js="\n".join(static_js),
)
else:
res = render_template(
"report.html",
int=int,
round=round,
escape=escape,
str=str,
requests_statistics=requests_statistics,
failures_statistics=failures_statistics,
exceptions_statistics=exceptions_statistics,
start_time=start_time,
end_time=end_time,
host=host,
history=history,
static_js="\n".join(static_js),
static_css="\n".join(static_css),
show_download_link=show_download_link,
locustfile=environment.locustfile,
tasks=dumps(task_data),
percentile1=stats_module.PERCENTILES_TO_CHART[0],
percentile2=stats_module.PERCENTILES_TO_CHART[1],
)

return res
28 changes: 28 additions & 0 deletions locust/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import csv
import signal
import gevent
from html import escape
from .util.rounding import proper_round

from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -685,6 +687,24 @@ def _cache_response_times(self, t: int) -> None:
for _ in range(len(self.response_times_cache) - cache_size):
self.response_times_cache.popitem(last=False)

def to_dict(self, escape_string_values=False):
return {
"method": escape(self.method or "") if escape_string_values else self.method,
"name": escape(self.name) if escape_string_values else self.name,
"safe_name": escape(self.name, quote=False),
"num_requests": self.num_requests,
"num_failures": self.num_failures,
"avg_response_time": self.avg_response_time,
"min_response_time": 0 if self.min_response_time is None else proper_round(self.min_response_time),
"max_response_time": proper_round(self.max_response_time),
"current_rps": self.current_rps,
"current_fail_per_sec": self.current_fail_per_sec,
"median_response_time": self.median_response_time,
"ninetieth_response_time": self.get_response_time_percentile(0.9),
"ninety_ninth_response_time": self.get_response_time_percentile(0.99),
"avg_content_length": self.avg_content_length,
}


class StatsError:
def __init__(self, method: str, name: str, error: Exception | str | None, occurrences: int = 0):
Expand Down Expand Up @@ -745,6 +765,14 @@ def _getattr(obj: "StatsError", key: str, default: Optional[Any]) -> Optional[An
def unserialize(cls, data: StatsErrorDict) -> "StatsError":
return cls(data["method"], data["name"], data["error"], data["occurrences"])

def to_dict(self, escape_string_values=False):
return {
"method": escape(self.method),
"name": escape(self.name),
"error": escape(self.parse_error(self.error)),
"occurrences": self.occurrences,
}


def avg(values: List[float | int]) -> float:
return sum(values, 0.0) / max(len(values), 1)
Expand Down
27 changes: 26 additions & 1 deletion locust/test/test_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,18 @@ def tick(self):
self.assertRegex(response.text, re_spawn_rate)
self.assertNotRegex(response.text, re_disabled_spawn_rate)

def test_html_stats_report(self):
self.environment.locustfile = "locust.py"
self.environment.host = "http://localhost"

response = requests.get("http://127.0.0.1:%i/stats/report" % self.web_port)
self.assertEqual(200, response.status_code)

d = pq(response.content.decode("utf-8"))

self.assertIn("Script: <span>locust.py</span>", str(d))
self.assertIn("Target Host: <span>http://localhost</span>", str(d))


class TestWebUIAuth(LocustTestCase):
def setUp(self):
Expand Down Expand Up @@ -1133,7 +1145,7 @@ def test_request_stats_full_history_csv(self):
self.assertEqual("Aggregated", rows[3][3])


class TestModernWebUi(LocustTestCase, _HeaderCheckMixin):
class TestModernWebUI(LocustTestCase, _HeaderCheckMixin):
def setUp(self):
super().setUp()

Expand Down Expand Up @@ -1186,3 +1198,16 @@ def test_web_ui_no_runner(self):
self.assertEqual("Error: Locust Environment does not have any runner", response.text)
finally:
web_ui.stop()

def test_html_stats_report(self):
self.environment.locustfile = "locust.py"
self.environment.host = "http://localhost"

response = requests.get("http://127.0.0.1:%i/stats/report" % self.web_port)
self.assertEqual(200, response.status_code)

d = pq(response.content.decode("utf-8"))

self.assertTrue(d("#root"))
self.assertIn('"locustfile": "locust.py"', str(d))
self.assertIn('"host": "http://localhost"', str(d))
41 changes: 18 additions & 23 deletions locust/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
from .stats import StatsCSV
from .user.inspectuser import get_ratio
from .util.cache import memoize
from .util.rounding import proper_round
from .util.timespan import parse_timespan
from .html import get_html_report
from flask_cors import CORS
Expand Down Expand Up @@ -111,9 +110,9 @@ def __init__(
self.app = app
app.jinja_env.add_extension("jinja2.ext.do")
app.debug = True
root_path = os.path.dirname(os.path.abspath(__file__))
app.root_path = root_path
self.webui_build_path = f"{root_path}/webui/dist"
self.root_path = os.path.dirname(os.path.abspath(__file__))
app.root_path = self.root_path
self.webui_build_path = f"{self.root_path}/webui/dist"
self.app.config["BASIC_AUTH_ENABLED"] = False
self.auth: Optional[BasicAuth] = None
self.greenlet: Optional[gevent.Greenlet] = None
Expand Down Expand Up @@ -278,7 +277,20 @@ def reset_stats() -> str:
@app.route("/stats/report")
@self.auth_required_if_enabled
def stats_report() -> Response:
res = get_html_report(self.environment, show_download_link=not request.args.get("download"))
if self.modern_ui:
self.set_static_modern_ui()
static_path = f"{self.webui_build_path}/assets"
else:
static_path = f"{self.root_path}/static"

theme = request.args.get("theme", "")
res = get_html_report(
self.environment,
static_path=static_path,
show_download_link=not request.args.get("download"),
use_modern_ui=self.modern_ui,
theme=theme,
)
if request.args.get("download"):
res = app.make_response(res)
res.headers["Content-Disposition"] = f"attachment;filename=report_{time()}.html"
Expand Down Expand Up @@ -367,24 +379,7 @@ def request_stats() -> Response:
return jsonify(report)

for s in chain(sort_stats(environment.runner.stats.entries), [environment.runner.stats.total]):
stats.append(
{
"method": s.method,
"name": s.name,
"safe_name": escape(s.name, quote=False),
"num_requests": s.num_requests,
"num_failures": s.num_failures,
"avg_response_time": s.avg_response_time,
"min_response_time": 0 if s.min_response_time is None else proper_round(s.min_response_time),
"max_response_time": proper_round(s.max_response_time),
"current_rps": s.current_rps,
"current_fail_per_sec": s.current_fail_per_sec,
"median_response_time": s.median_response_time,
"ninetieth_response_time": s.get_response_time_percentile(0.9),
"ninety_ninth_response_time": s.get_response_time_percentile(0.99),
"avg_content_length": s.avg_content_length,
}
)
stats.append(s.to_dict())

for e in environment.runner.errors.values():
err_dict = e.serialize()
Expand Down
4 changes: 4 additions & 0 deletions locust/webui/.eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@
"pattern": "App",
"group": "internal"
},
{
"pattern": "Report",
"group": "internal"
},
{
"pattern": "{api,components,constants,hooks,redux,styles,types,utils}/**",
"group": "internal"
Expand Down
7 changes: 0 additions & 7 deletions locust/webui/dist/assets/index-0c9ff576.js

This file was deleted.

Loading

0 comments on commit 711e9fd

Please sign in to comment.