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

Add a modern web UI based on React, MaterialUI and Vite #2405

Merged
merged 34 commits into from
Oct 10, 2023
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e674859
Add modern_ui boolean argument
Sep 20, 2023
aee9c3d
Return UIExtraArgOptions as dict
Sep 20, 2023
ce6d231
Modify response from /stats/reset to be json
Sep 20, 2023
b07fab7
Add react frontend
Sep 20, 2023
55fb310
Add dashboard github workflows
Sep 22, 2023
6dcb934
Modify index route to return modern ui when modern_ui=True
Sep 20, 2023
f66665a
Add extend_modern_web_ui
Sep 21, 2023
1788387
Update Makefile
Sep 22, 2023
7048efb
Update Dockerfile
Sep 22, 2023
77a140c
Replace Webpack with Vite
Sep 25, 2023
bc010bd
Add error message to FailuresTable
Sep 25, 2023
08ff63f
Update MANIFEST.in
Sep 25, 2023
8eae174
Update developing-locust.rst
Sep 21, 2023
08aa968
Move dashboard to locust/webui
Sep 25, 2023
58b9d63
Add TestModernWebUi
Sep 25, 2023
5d9eda6
Add run_time to template_args
Sep 25, 2023
c1bb647
Add experimental warning to argument_parser
Sep 26, 2023
4e26d70
Update quickstart.rst
Sep 26, 2023
bac8a15
Fix: autofill user_count with num_users
Sep 27, 2023
4a67676
Fix: fetching report on first load
Sep 27, 2023
98603dd
Fix: StateButttons show new button on spawning state
Sep 27, 2023
018d75e
Fix: swarm spawning does not fetch report
Sep 29, 2023
5508e36
Fix: failures table html escaping
Sep 29, 2023
9e0b43b
Fix: replace lastElement with array.at
Oct 6, 2023
416c5a1
Fix: re-write query string to object
Oct 6, 2023
9585911
Fix: re-write pushQuery
Oct 6, 2023
85e9407
Fix: resize logo.png
Oct 6, 2023
f1b74f1
Remove google fonts
Oct 6, 2023
866e98f
Separate production and dev dependencies
Oct 6, 2023
4bcfaa1
Rename merge to shallowMerge
Oct 6, 2023
c964c65
Use RTK Draft
Oct 6, 2023
c4ffc33
Replace asyncRequest with RTK createApi
Oct 10, 2023
573f363
Fix: promote spawning state to running
Oct 10, 2023
804a19a
Combine ModernUi tests into single test
Oct 10, 2023
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 .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ jobs:
- run: python -m pip install tox
- run: python -m tox -e ${{ matrix.tox }}

lint_typecheck_webui:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
- uses: borales/actions-yarn@v3
with:
cmd: webui:install
- uses: borales/actions-yarn@v3
with:
cmd: webui:lint
- uses: borales/actions-yarn@v3
with:
cmd: webui:type-check

verify_docker_build:
name: Always - Docker verify, push to tag 'master' if on master
runs-on: ubuntu-latest
Expand Down
9 changes: 9 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
FROM node:18.0.0-alpine as webui-builder

ADD locust/webui locust/webui
ADD package.json .

RUN yarn webui:install --production
RUN yarn webui:build

FROM python:3.11-slim as base

FROM base as builder
Expand All @@ -11,6 +19,7 @@ RUN pip install /build/

FROM base
COPY --from=builder /opt/venv /opt/venv
COPY --from=webui-builder locust/webui/dist locust/webui/dist
ENV PATH="/opt/venv/bin:$PATH"
# turn off python output buffering
ENV PYTHONUNBUFFERED=1
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ include LICENSE
include locust/py.typed
recursive-include locust/static *
recursive-include locust/templates *
recursive-include locust/webui/dist *
3 changes: 3 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ test:
build:
rm -f dist/* && python3 -m pip install --upgrade build && python3 -m build .

frontend_build:
yarn webui:install && yarn webui:build

release: build
twine upload dist/*

Expand Down
68 changes: 68 additions & 0 deletions docs/developing-locust.rst
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,71 @@ Or you can make sass watch for changes to the ``.sass`` files and automatically
$ make sass_watch

The CSS files that are generated by SASS should be checked into version control.


Making changes to Locust's Modern Web UI
========================================

The modern Web UI is built using React and Typescript

## Setup

### Node

Install node using nvm to easily switch between node version

- Copy and run the install line from [nvm](https://github.com/nvm-sh/nvm) (starts with curl/wget ...)

- Verify nvm was installed correctly

```bash
nvm --version
```

- Install the proper Node version according to engines in the locust/webui/package.json

```bash
nvm install {version}
nvm alias default {version}
```

### Yarn

- Install Yarn from their official website (avoid installing through Node if possible)
- Verify yarn was installed correctly

```bash
yarn --version
```

- Next in web, install all dependencies

```bash
cd locust/webui
yarn
```

## Developing

To develop the frontend, run `yarn dev`. This will start the Vite dev server and allow for viewing and editing the frontend, without needing to a run a locust web server

To develop while running a locust instance, run `yarn dev:watch`. This will output the static files to the `dist` directory. Then simply a locust instance using the `--modern-ui` flag. Vite will automatically detect any changed files and re-build as needed. Simply refresh the page to view the changes

To compile the webui, run `yarn build`

The frontend can additionally be built using make:
```bash
make frontend_build
```

## Linting

Run `yarn lint` to detect lint failures in the frontend project. Running `yarn lint --fix` will resolve any issues that are automatically resolvable. Your IDE can aditionally be configured with ESLint to resolve these issues on save.

## Formatting

Run `yarn format` to fix any formatting issues in the frontend project. Once again your IDE can be configured to automatically format on save.

## Typechecking

We use Typescript in the frontend project. Run `yarn type-check` to find any issues.
Binary file added docs/images/modern-webui-splash-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 9 additions & 1 deletion docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ The following screenshots show what it might look like when running this test us

If you need some help digging into server side problems, or you're having trouble generating enough load to saturate your system, have a look at the `Locust FAQ <https://github.com/locustio/locust/wiki/FAQ#increase-my-request-raterps>`_.

There is now a modern version of the Web UI available! Try it out by setting the ``--modern-ui`` flag.

.. image:: images/modern-webui-splash-screenshot.png

.. note::

This feature is experimental and you may experience breaking changes.

Direct command line usage / headless
====================================

Expand Down Expand Up @@ -89,7 +97,7 @@ To run Locust distributed across multiple Python processes or machines, you star
with the ``--master`` command line parameter, and then any number of Locust worker processes using the ``--worker``
command line parameter. See :ref:`running-distributed` for more info.

To see all available options type: ``locust --help`` or check :ref:`configuration`.
To see all available options type: ```locust --help`` or check :ref:`configuration`.

Next steps
==========
Expand Down
163 changes: 163 additions & 0 deletions examples/extend_modern_web_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
"""
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
import json
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, request


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:

def get_content_length_stats():
"""
This is used by the Content Length tab in the
extended web UI to show the 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.
return stats_tmp[:500]
return stats

@environment.web_ui.app.after_request
def extend_stats_response(response):
if request.path != "/stats/requests":
return response

response.set_data(
json.dumps(
{**response.json, "extended_stats": [{"key": "content-length", "data": get_content_length_stats()}]}
)
)

return response

@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()
# set the static paths to use the modern ui
environment.web_ui.set_static_modern_ui()

return render_template(
"index.html",
template_args={
**environment.web_ui.template_args,
"extended_tabs": [{"title": "Content Length", "key": "content-length"}],
"extended_tables": [
{
"key": "content-length",
"structure": [
{"key": "name", "title": "Name"},
{"key": "content_length", "title": "Total content length"},
],
}
],
"extended_csv_files": [
{"href": "/content-length/csv", "title": "Download content length statistics CSV"}
],
},
)

@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 = f"content_length{time()}.csv"
disposition = f"attachment;filename={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(f"\"{url}\",{inner_stats['content-length']:.2f}")
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.add_listener
def on_request(request_type, name, response_time, response_length, exception, context, **kwargs):
"""
Event handler that get triggered on every 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 = {}
14 changes: 10 additions & 4 deletions locust/argument_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
import platform
import sys
import textwrap
from typing import Dict, List, NamedTuple, Optional

from typing import Dict, List, NamedTuple, Optional, Any
import configargparse

import locust
Expand Down Expand Up @@ -359,6 +358,13 @@ def setup_parser_arguments(parser):
help="Enable select boxes in the web interface to choose from all available User classes and Shape classes",
env_var="LOCUST_USERCLASS_PICKER",
)
web_ui_group.add_argument(
"--modern-ui",
default=False,
action="store_true",
help="*Experimental* enable using a modern React frontend as the Web UI",
env_var="LOCUST_MODERN_UI",
)

master_group = parser.add_argument_group(
"Master options",
Expand Down Expand Up @@ -622,7 +628,7 @@ class UIExtraArgOptions(NamedTuple):
choices: Optional[List[str]] = None


def ui_extra_args_dict(args=None) -> Dict[str, UIExtraArgOptions]:
def ui_extra_args_dict(args=None) -> Dict[str, Dict[str, Any]]:
"""Get all the UI visible arguments"""
locust_args = default_args_dict()

Expand All @@ -635,7 +641,7 @@ def ui_extra_args_dict(args=None) -> Dict[str, UIExtraArgOptions]:
is_secret=k in parser.secret_args_included_in_web_ui,
help_text=parser.args_included_in_web_ui[k].help,
choices=parser.args_included_in_web_ui[k].choices,
)
)._asdict()
for k, v in all_args.items()
if k not in locust_args and k in parser.args_included_in_web_ui
}
Expand Down
2 changes: 2 additions & 0 deletions locust/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def create_web_ui(
stats_csv_writer: Optional[StatsCSV] = None,
delayed_start=False,
userclass_picker_is_active=False,
modern_ui=False,
) -> WebUI:
"""
Creates a :class:`WebUI <locust.web.WebUI>` instance for this Environment and start running the web server
Expand All @@ -191,6 +192,7 @@ def create_web_ui(
stats_csv_writer=stats_csv_writer,
delayed_start=delayed_start,
userclass_picker_is_active=userclass_picker_is_active,
modern_ui=modern_ui,
)
return self.web_ui

Expand Down
1 change: 1 addition & 0 deletions locust/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,7 @@ def is_valid_percentile(parameter):
stats_csv_writer=stats_csv_writer,
delayed_start=True,
userclass_picker_is_active=options.class_picker,
modern_ui=options.modern_ui,
)
except AuthCredentialsError:
logger.error("Credentials supplied with --web-auth should have the format: username:password")
Expand Down
2 changes: 1 addition & 1 deletion locust/test/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ def _(parser, **kw):
self.assertIn("a1", extra_args)
self.assertNotIn("a2", extra_args)
self.assertIn("a3", extra_args)
self.assertEqual("v1", extra_args["a1"].default_value)
self.assertEqual("v1", extra_args["a1"]["default_value"])


class TestFindLocustfiles(LocustTestCase):
Expand Down
Loading
Loading