locust 2.32.2.dev7__tar.gz → 2.32.2.dev34__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/PKG-INFO +1 -1
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/_version.py +2 -2
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/argument_parser.py +8 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/env.py +2 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/html.py +3 -1
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/main.py +12 -4
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/runners.py +3 -2
- locust-2.32.2.dev34/locust/util/date.py +23 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/web.py +38 -25
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/report.html +1 -1
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/poetry.lock +48 -1
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/pyproject.toml +2 -1
- locust-2.32.2.dev7/locust/util/date.py +0 -5
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/LICENSE +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/README.md +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/__init__.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/__main__.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/clients.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/__init__.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/fasthttp.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/mongodb.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/contrib/postgres.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/debug.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/dispatch.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/event.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/exception.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/input_events.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/log.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/py.typed +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/rpc/__init__.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/rpc/protocol.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/rpc/zmqrpc.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/shape.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/stats.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/__init__.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/inspectuser.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/sequential_taskset.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/task.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/users.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/user/wait_time.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/__init__.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/cache.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/deprecation.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/directory.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/exception_handler.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/load_locustfile.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/rounding.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/timespan.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/util/url.py +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/assets/favicon-dark.png +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/assets/favicon-light.png +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/assets/index-CV_-ndKF.js +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/auth.html +0 -0
- {locust-2.32.2.dev7 → locust-2.32.2.dev34}/locust/webui/dist/index.html +0 -0
@@ -14,7 +14,7 @@ __version_tuple__: VERSION_TUPLE
|
|
14
14
|
version_tuple: VERSION_TUPLE
|
15
15
|
|
16
16
|
|
17
|
-
__version__ = "2.32.2.
|
17
|
+
__version__ = "2.32.2.dev34"
|
18
18
|
version = __version__
|
19
|
-
__version_tuple__ = (2, 32, 2, "
|
19
|
+
__version_tuple__ = (2, 32, 2, "dev34")
|
20
20
|
version_tuple = __version_tuple__
|
@@ -612,6 +612,14 @@ Typically ONLY these options (and --locustfile) need to be specified on workers,
|
|
612
612
|
env_var="LOCUST_MASTER_NODE_PORT",
|
613
613
|
)
|
614
614
|
|
615
|
+
web_ui_group.add_argument(
|
616
|
+
"--web-base-path",
|
617
|
+
type=str,
|
618
|
+
default="",
|
619
|
+
help="Base path for the web interface (e.g., '/locust'). Default is empty (root path).",
|
620
|
+
env_var="LOCUST_web_base_path",
|
621
|
+
)
|
622
|
+
|
615
623
|
tag_group = parser.add_argument_group(
|
616
624
|
"Tag options",
|
617
625
|
"Locust tasks can be tagged using the @tag decorator. These options let specify which tasks to include or exclude during a test.",
|
@@ -165,6 +165,7 @@ class Environment:
|
|
165
165
|
self,
|
166
166
|
host="",
|
167
167
|
port=8089,
|
168
|
+
web_base_path: str | None = None,
|
168
169
|
web_login: bool = False,
|
169
170
|
tls_cert: str | None = None,
|
170
171
|
tls_key: str | None = None,
|
@@ -199,6 +200,7 @@ class Environment:
|
|
199
200
|
delayed_start=delayed_start,
|
200
201
|
userclass_picker_is_active=userclass_picker_is_active,
|
201
202
|
build_path=build_path,
|
203
|
+
web_base_path=web_base_path,
|
202
204
|
)
|
203
205
|
return self.web_ui
|
204
206
|
|
@@ -12,7 +12,7 @@ from . import stats as stats_module
|
|
12
12
|
from .runners import STATE_STOPPED, STATE_STOPPING, MasterRunner
|
13
13
|
from .stats import sort_stats, update_stats_history
|
14
14
|
from .user.inspectuser import get_ratio
|
15
|
-
from .util.date import format_utc_timestamp
|
15
|
+
from .util.date import format_duration, format_utc_timestamp
|
16
16
|
|
17
17
|
PERCENTILES_FOR_HTML_REPORT = [0.50, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1.0]
|
18
18
|
DEFAULT_BUILD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "webui", "dist")
|
@@ -36,6 +36,7 @@ def get_html_report(
|
|
36
36
|
if end_ts := stats.last_request_timestamp:
|
37
37
|
end_time = format_utc_timestamp(end_ts)
|
38
38
|
else:
|
39
|
+
end_ts = stats.start_time
|
39
40
|
end_time = start_time
|
40
41
|
|
41
42
|
host = None
|
@@ -88,6 +89,7 @@ def get_html_report(
|
|
88
89
|
],
|
89
90
|
"start_time": start_time,
|
90
91
|
"end_time": end_time,
|
92
|
+
"duration": format_duration(stats.start_time, end_ts),
|
91
93
|
"host": escape(str(host)),
|
92
94
|
"history": history,
|
93
95
|
"show_download_link": show_download_link,
|
@@ -454,7 +454,9 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
|
|
454
454
|
elif options.worker:
|
455
455
|
try:
|
456
456
|
runner = environment.create_worker_runner(options.master_host, options.master_port)
|
457
|
-
logger.debug(
|
457
|
+
logger.debug(
|
458
|
+
"Connected to locust master: %s:%s%s", options.master_host, options.master_port, options.web_base_path
|
459
|
+
)
|
458
460
|
except OSError as e:
|
459
461
|
logger.error("Failed to connect to the Locust master: %s", e)
|
460
462
|
sys.exit(-1)
|
@@ -490,26 +492,32 @@ See https://github.com/locustio/locust/wiki/Installation#increasing-maximum-numb
|
|
490
492
|
if not options.headless and not options.worker:
|
491
493
|
protocol = "https" if options.tls_cert and options.tls_key else "http"
|
492
494
|
|
495
|
+
if options.web_base_path and options.web_base_path[0] != "/":
|
496
|
+
logger.error(
|
497
|
+
f"Invalid format for --web-base-path argument ({options.web_base_path}): the url path must start with a slash."
|
498
|
+
)
|
499
|
+
sys.exit(1)
|
493
500
|
if options.web_host == "*":
|
494
501
|
# special check for "*" so that we're consistent with --master-bind-host
|
495
502
|
web_host = ""
|
496
503
|
else:
|
497
504
|
web_host = options.web_host
|
498
505
|
if web_host:
|
499
|
-
logger.info(f"Starting web interface at {protocol}://{web_host}:{options.web_port}")
|
506
|
+
logger.info(f"Starting web interface at {protocol}://{web_host}:{options.web_port}{options.web_base_path}")
|
500
507
|
if options.web_host_display_name:
|
501
508
|
logger.info(f"Starting web interface at {options.web_host_display_name}")
|
502
509
|
else:
|
503
510
|
if os.name == "nt":
|
504
511
|
logger.info(
|
505
|
-
f"Starting web interface at {protocol}://localhost:{options.web_port} (accepting connections from all network interfaces)"
|
512
|
+
f"Starting web interface at {protocol}://localhost:{options.web_port}{options.web_base_path} (accepting connections from all network interfaces)"
|
506
513
|
)
|
507
514
|
else:
|
508
|
-
logger.info(f"Starting web interface at {protocol}://0.0.0.0:{options.web_port}")
|
515
|
+
logger.info(f"Starting web interface at {protocol}://0.0.0.0:{options.web_port}{options.web_base_path}")
|
509
516
|
|
510
517
|
web_ui = environment.create_web_ui(
|
511
518
|
host=web_host,
|
512
519
|
port=options.web_port,
|
520
|
+
web_base_path=options.web_base_path,
|
513
521
|
web_login=options.web_login,
|
514
522
|
tls_cert=options.tls_cert,
|
515
523
|
tls_key=options.tls_key,
|
@@ -1216,6 +1216,7 @@ class WorkerRunner(DistributedRunner):
|
|
1216
1216
|
self.client_id = socket.gethostname() + "_" + uuid4().hex
|
1217
1217
|
self.master_host = master_host
|
1218
1218
|
self.master_port = master_port
|
1219
|
+
self.web_base_path = environment.parsed_options.web_base_path if environment.parsed_options else ""
|
1219
1220
|
self.logs: list[str] = []
|
1220
1221
|
self.worker_cpu_warning_emitted = False
|
1221
1222
|
self._users_dispatcher: UsersDispatcher | None = None
|
@@ -1475,11 +1476,11 @@ class WorkerRunner(DistributedRunner):
|
|
1475
1476
|
if not success:
|
1476
1477
|
if self.retry < 3:
|
1477
1478
|
logger.debug(
|
1478
|
-
f"Failed to connect to master {self.master_host}:{self.master_port}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
|
1479
|
+
f"Failed to connect to master {self.master_host}:{self.master_port}{self.web_base_path}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
|
1479
1480
|
)
|
1480
1481
|
else:
|
1481
1482
|
logger.warning(
|
1482
|
-
f"Failed to connect to master {self.master_host}:{self.master_port}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
|
1483
|
+
f"Failed to connect to master {self.master_host}:{self.master_port}{self.web_base_path}, retry {self.retry}/{CONNECT_RETRY_COUNT}."
|
1483
1484
|
)
|
1484
1485
|
if self.retry > CONNECT_RETRY_COUNT:
|
1485
1486
|
raise ConnectionError()
|
@@ -0,0 +1,23 @@
|
|
1
|
+
from datetime import datetime, timezone
|
2
|
+
|
3
|
+
|
4
|
+
def format_utc_timestamp(unix_timestamp):
|
5
|
+
return datetime.fromtimestamp(int(unix_timestamp), timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
6
|
+
|
7
|
+
|
8
|
+
def format_safe_timestamp(unix_timestamp):
|
9
|
+
return datetime.fromtimestamp(int(unix_timestamp)).strftime("%Y-%m-%d-%Hh%M")
|
10
|
+
|
11
|
+
|
12
|
+
def format_duration(start_time, end_time):
|
13
|
+
seconds = int(end_time) - int(start_time)
|
14
|
+
days = seconds // 86400
|
15
|
+
hours = (seconds % 86400) // 3600
|
16
|
+
minutes = (seconds % 3600) // 60
|
17
|
+
seconds = seconds % 60
|
18
|
+
|
19
|
+
time_parts = [(days, "day"), (hours, "hour"), (minutes, "minute"), (seconds, "second")]
|
20
|
+
|
21
|
+
parts = [f"{value} {label}{'s' if value != 1 else ''}" for value, label in time_parts if value > 0]
|
22
|
+
|
23
|
+
return " and ".join(filter(None, [", ".join(parts[:-1])] + parts[-1:])) if parts else "0 seconds"
|
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, TypedDict
|
|
15
15
|
|
16
16
|
import gevent
|
17
17
|
from flask import (
|
18
|
+
Blueprint,
|
18
19
|
Flask,
|
19
20
|
Response,
|
20
21
|
jsonify,
|
@@ -40,7 +41,7 @@ from .runners import STATE_MISSING, STATE_RUNNING, MasterRunner
|
|
40
41
|
from .stats import StatsCSV, StatsCSVFileWriter, StatsErrorDict, sort_stats
|
41
42
|
from .user.inspectuser import get_ratio
|
42
43
|
from .util.cache import memoize
|
43
|
-
from .util.date import
|
44
|
+
from .util.date import format_safe_timestamp
|
44
45
|
from .util.timespan import parse_timespan
|
45
46
|
|
46
47
|
if TYPE_CHECKING:
|
@@ -120,6 +121,7 @@ class WebUI:
|
|
120
121
|
environment: Environment,
|
121
122
|
host: str,
|
122
123
|
port: int,
|
124
|
+
web_base_path: str | None = None,
|
123
125
|
web_login: bool = False,
|
124
126
|
tls_cert: str | None = None,
|
125
127
|
tls_key: str | None = None,
|
@@ -161,20 +163,21 @@ class WebUI:
|
|
161
163
|
self.auth_args = {}
|
162
164
|
self.app.template_folder = build_path or DEFAULT_BUILD_PATH
|
163
165
|
self.app.static_url_path = "/assets/"
|
166
|
+
|
167
|
+
app_blueprint = Blueprint("locust", __name__, url_prefix=web_base_path)
|
164
168
|
# ensures static js files work on Windows
|
165
169
|
mimetypes.add_type("application/javascript", ".js")
|
166
|
-
|
167
170
|
if self.web_login:
|
168
171
|
self._login_manager = LoginManager()
|
169
172
|
self._login_manager.init_app(self.app)
|
170
|
-
self._login_manager.login_view = "login"
|
173
|
+
self._login_manager.login_view = "locust.login"
|
171
174
|
|
172
175
|
if environment.runner:
|
173
176
|
self.update_template_args()
|
174
177
|
if not delayed_start:
|
175
178
|
self.start()
|
176
179
|
|
177
|
-
@
|
180
|
+
@app_blueprint.errorhandler(Exception)
|
178
181
|
def handle_exception(error):
|
179
182
|
error_message = str(error)
|
180
183
|
error_code = getattr(error, "code", 500)
|
@@ -184,7 +187,7 @@ class WebUI:
|
|
184
187
|
)
|
185
188
|
return make_response(error_message, error_code)
|
186
189
|
|
187
|
-
@
|
190
|
+
@app_blueprint.route("/assets/<path:path>")
|
188
191
|
def send_assets(path):
|
189
192
|
directory = (
|
190
193
|
os.path.join(self.app.template_folder, "assets")
|
@@ -194,7 +197,7 @@ class WebUI:
|
|
194
197
|
|
195
198
|
return send_from_directory(directory, path)
|
196
199
|
|
197
|
-
@
|
200
|
+
@app_blueprint.route("/")
|
198
201
|
@self.auth_required_if_enabled
|
199
202
|
def index() -> str | Response:
|
200
203
|
if not environment.runner:
|
@@ -203,7 +206,7 @@ class WebUI:
|
|
203
206
|
|
204
207
|
return render_template("index.html", template_args=self.template_args)
|
205
208
|
|
206
|
-
@
|
209
|
+
@app_blueprint.route("/swarm", methods=["POST"])
|
207
210
|
@self.auth_required_if_enabled
|
208
211
|
def swarm() -> Response:
|
209
212
|
assert request.method == "POST"
|
@@ -317,7 +320,7 @@ class WebUI:
|
|
317
320
|
else:
|
318
321
|
return jsonify({"success": False, "message": "No runner", "host": environment.host})
|
319
322
|
|
320
|
-
@
|
323
|
+
@app_blueprint.route("/stop")
|
321
324
|
@self.auth_required_if_enabled
|
322
325
|
def stop() -> Response:
|
323
326
|
if self._swarm_greenlet is not None:
|
@@ -327,7 +330,7 @@ class WebUI:
|
|
327
330
|
environment.runner.stop()
|
328
331
|
return jsonify({"success": True, "message": "Test stopped"})
|
329
332
|
|
330
|
-
@
|
333
|
+
@app_blueprint.route("/stats/reset")
|
331
334
|
@self.auth_required_if_enabled
|
332
335
|
def reset_stats() -> str:
|
333
336
|
environment.events.reset_stats.fire()
|
@@ -336,7 +339,7 @@ class WebUI:
|
|
336
339
|
environment.runner.exceptions = {}
|
337
340
|
return "ok"
|
338
341
|
|
339
|
-
@
|
342
|
+
@app_blueprint.route("/stats/report")
|
340
343
|
@self.auth_required_if_enabled
|
341
344
|
def stats_report() -> Response:
|
342
345
|
theme = request.args.get("theme", "")
|
@@ -347,17 +350,25 @@ class WebUI:
|
|
347
350
|
)
|
348
351
|
if request.args.get("download"):
|
349
352
|
res = app.make_response(res)
|
350
|
-
|
353
|
+
host = f"_{self.environment.host}" if self.environment.host else ""
|
354
|
+
res.headers["Content-Disposition"] = (
|
355
|
+
f"attachment;filename=Locust_{format_safe_timestamp(self.environment.stats.start_time)}_"
|
356
|
+
+ f"{self.environment.locustfile}{host}.html"
|
357
|
+
)
|
351
358
|
return res
|
352
359
|
|
353
360
|
def _download_csv_suggest_file_name(suggest_filename_prefix: str) -> str:
|
354
361
|
"""Generate csv file download attachment filename suggestion.
|
355
362
|
|
356
363
|
Arguments:
|
357
|
-
suggest_filename_prefix: Prefix of the filename to suggest for saving the download.
|
364
|
+
suggest_filename_prefix: Prefix of the filename to suggest for saving the download.
|
365
|
+
Will be appended with timestamp.
|
358
366
|
"""
|
359
|
-
|
360
|
-
return
|
367
|
+
host = f"_{self.environment.host}" if self.environment.host else ""
|
368
|
+
return (
|
369
|
+
f"Locust_{format_safe_timestamp(self.environment.stats.start_time)}_"
|
370
|
+
+ f"{self.environment.locustfile}{host}_{suggest_filename_prefix}.csv"
|
371
|
+
)
|
361
372
|
|
362
373
|
def _download_csv_response(csv_data: str, filename_prefix: str) -> Response:
|
363
374
|
"""Generate csv file download response with 'csv_data'.
|
@@ -374,7 +385,7 @@ class WebUI:
|
|
374
385
|
)
|
375
386
|
return response
|
376
387
|
|
377
|
-
@
|
388
|
+
@app_blueprint.route("/stats/requests/csv")
|
378
389
|
@self.auth_required_if_enabled
|
379
390
|
def request_stats_csv() -> Response:
|
380
391
|
data = StringIO()
|
@@ -382,7 +393,7 @@ class WebUI:
|
|
382
393
|
self.stats_csv_writer.requests_csv(writer)
|
383
394
|
return _download_csv_response(data.getvalue(), "requests")
|
384
395
|
|
385
|
-
@
|
396
|
+
@app_blueprint.route("/stats/requests_full_history/csv")
|
386
397
|
@self.auth_required_if_enabled
|
387
398
|
def request_stats_full_history_csv() -> Response:
|
388
399
|
options = self.environment.parsed_options
|
@@ -400,7 +411,7 @@ class WebUI:
|
|
400
411
|
|
401
412
|
return make_response("Error: Server was not started with option to generate full history.", 404)
|
402
413
|
|
403
|
-
@
|
414
|
+
@app_blueprint.route("/stats/failures/csv")
|
404
415
|
@self.auth_required_if_enabled
|
405
416
|
def failures_stats_csv() -> Response:
|
406
417
|
data = StringIO()
|
@@ -408,7 +419,7 @@ class WebUI:
|
|
408
419
|
self.stats_csv_writer.failures_csv(writer)
|
409
420
|
return _download_csv_response(data.getvalue(), "failures")
|
410
421
|
|
411
|
-
@
|
422
|
+
@app_blueprint.route("/stats/requests")
|
412
423
|
@self.auth_required_if_enabled
|
413
424
|
@memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True)
|
414
425
|
def request_stats() -> Response:
|
@@ -478,7 +489,7 @@ class WebUI:
|
|
478
489
|
|
479
490
|
return jsonify(report)
|
480
491
|
|
481
|
-
@
|
492
|
+
@app_blueprint.route("/exceptions")
|
482
493
|
@self.auth_required_if_enabled
|
483
494
|
def exceptions() -> Response:
|
484
495
|
return jsonify(
|
@@ -495,7 +506,7 @@ class WebUI:
|
|
495
506
|
}
|
496
507
|
)
|
497
508
|
|
498
|
-
@
|
509
|
+
@app_blueprint.route("/exceptions/csv")
|
499
510
|
@self.auth_required_if_enabled
|
500
511
|
def exceptions_csv() -> Response:
|
501
512
|
data = StringIO()
|
@@ -503,7 +514,7 @@ class WebUI:
|
|
503
514
|
self.stats_csv_writer.exceptions_csv(writer)
|
504
515
|
return _download_csv_response(data.getvalue(), "exceptions")
|
505
516
|
|
506
|
-
@
|
517
|
+
@app_blueprint.route("/tasks")
|
507
518
|
@self.auth_required_if_enabled
|
508
519
|
def tasks() -> dict[str, dict[str, dict[str, float]]]:
|
509
520
|
runner = self.environment.runner
|
@@ -523,15 +534,15 @@ class WebUI:
|
|
523
534
|
}
|
524
535
|
return task_data
|
525
536
|
|
526
|
-
@
|
537
|
+
@app_blueprint.route("/logs")
|
527
538
|
@self.auth_required_if_enabled
|
528
539
|
def logs():
|
529
540
|
return jsonify({"master": get_logs(), "workers": self.environment.worker_logs})
|
530
541
|
|
531
|
-
@
|
542
|
+
@app_blueprint.route("/login")
|
532
543
|
def login():
|
533
544
|
if not self.web_login:
|
534
|
-
return redirect(url_for("index"))
|
545
|
+
return redirect(url_for("locust.index"))
|
535
546
|
|
536
547
|
self.auth_args["error"] = session.get("auth_error", None)
|
537
548
|
self.auth_args["info"] = session.get("auth_info", None)
|
@@ -541,7 +552,7 @@ class WebUI:
|
|
541
552
|
auth_args=self.auth_args,
|
542
553
|
)
|
543
554
|
|
544
|
-
@
|
555
|
+
@app_blueprint.route("/user", methods=["POST"])
|
545
556
|
def update_user():
|
546
557
|
assert request.method == "POST"
|
547
558
|
|
@@ -550,6 +561,8 @@ class WebUI:
|
|
550
561
|
|
551
562
|
return {}, 201
|
552
563
|
|
564
|
+
app.register_blueprint(app_blueprint)
|
565
|
+
|
553
566
|
@property
|
554
567
|
def login_manager(self):
|
555
568
|
if self.web_login:
|
@@ -220,7 +220,7 @@ PERFORMANCE OF THIS SOFTWARE.
|
|
220
220
|
<span style="color:${f};">
|
221
221
|
${h}: ${Rbe({chartValueFormatter:i,value:d})}
|
222
222
|
</span>
|
223
|
-
`,""):"No data",borderWidth:0},xAxis:{type:"time",min:(e.time||[new Date().toISOString()])[0],startValue:(e.time||[])[0],axisLabel:{formatter:Lbe}},grid:{left:60,right:40},yAxis:Ebe({splitAxis:a,yAxisLabels:o}),series:NW({charts:e,lines:r,scatterplot:s}),color:n,toolbox:{right:10,feature:{dataZoom:{title:{zoom:"Zoom Select",back:"Zoom Reset"},yAxisIndex:!1},saveAsImage:{name:t.replace(/\s+/g,"_").toLowerCase()+"_"+new Date().getTime()/1e3,title:"Download as PNG",emphasis:{iconStyle:{textPosition:"left"}}}}}}),Nbe=e=>({symbol:"none",label:{formatter:t=>`Run #${t.dataIndex+1}`,padding:[0,0,8,0]},data:(e.markers||[]).map(t=>({xAxis:t}))}),zbe=e=>t=>{const{batch:r}=t;if(!r)return;const[{start:n,startValue:i,end:a}]=r,o=n>0&&a<=100||i>0;e.setOption({dataZoom:[{type:"slider",show:o}]})},Bbe=aQ;function Fbe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s,shouldReplaceMergeLines:l=!1}){const[u,c]=V.useState(null),f=Bbe(({theme:{isDarkMode:d}})=>d),h=V.useRef(null);return V.useEffect(()=>{if(!h.current)return;const d=bce(h.current);d.setOption(Obe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s})),d.on("datazoom",zbe(d));const p=()=>d.resize();return window.addEventListener("resize",p),d.group="swarmCharts",Cce("swarmCharts"),c(d),()=>{Tce(d),window.removeEventListener("resize",p)}},[h]),V.useEffect(()=>{const d=r.every(({key:p})=>!!e[p]);u&&d&&u.setOption({series:r.map(({key:p,yAxisIndex:v,...g},y)=>({...g,data:e[p],...a?{yAxisIndex:v||y}:{},...y===0?{markLine:Nbe(e)}:{}}))})},[e,u,r]),V.useEffect(()=>{if(u){const{textColor:d,axisColor:p,backgroundColor:v,splitLine:g}=f?Lz.DARK:Lz.LIGHT;u.setOption({backgroundColor:v,textStyle:{color:d},title:{textStyle:{color:d}},legend:{icon:"circle",inactiveColor:d,textStyle:{color:d}},tooltip:{backgroundColor:v,textStyle:{color:d}},xAxis:{axisLine:{lineStyle:{color:p}}},yAxis:{axisLine:{lineStyle:{color:p}},splitLine:{lineStyle:{color:g}}}})}},[u,f]),V.useEffect(()=>{u&&u.setOption({series:NW({charts:e,lines:r,scatterplot:s})},l?{replaceMerge:["series"]}:void 0)},[r]),H.jsx("div",{ref:h,style:{width:"100%",height:"300px"}})}const Vbe=dl.percentilesToChart?dl.percentilesToChart.map(e=>({name:`${e*100}th percentile`,key:`responseTimePercentile${e}`})):[],Gbe=["#ff9f00","#9966CC","#8A2BE2","#8E4585","#E0B0FF","#C8A2C8","#E6E6FA"],$be=[{title:"Total Requests per Second",lines:[{name:"RPS",key:"currentRps"},{name:"Failures/s",key:"currentFailPerSec"}],colors:["#00ca5a","#ff6d6d"]},{title:"Response Times (ms)",lines:Vbe,colors:Gbe},{title:"Number of Users",lines:[{name:"Number of Users",key:"userCount"}],colors:["#0099ff"]}];function zW({charts:e}){return H.jsx("div",{children:$be.map((t,r)=>H.jsx(Fbe,{...t,charts:e},`swarm-chart-${r}`))})}const Hbe=({ui:{charts:e}})=>({charts:e});Kd(Hbe)(zW);function Wbe(e){return(e*100).toFixed(1)+"%"}function EC({classRatio:e}){return H.jsx("ul",{children:Object.entries(e).map(([t,{ratio:r,tasks:n}])=>H.jsxs("li",{children:[`${Wbe(r)} ${t}`,n&&H.jsx(EC,{classRatio:n})]},`nested-ratio-${t}`))})}function BW({ratios:{perClass:e,total:t}}){return!e&&!t?null:H.jsxs("div",{children:[e&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Ratio Per Class"}),H.jsx(EC,{classRatio:e})]}),t&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Total Ratio"}),H.jsx(EC,{classRatio:t})]})]})}const Ube=({ui:{ratios:e}})=>({ratios:e});Kd(Ube)(BW);const xS={DARK:"dark",LIGHT:"light"},FW=localStorage.theme===xS.DARK||!("theme"in localStorage)&&window.matchMedia("(prefers-color-scheme: dark)").matches?xS.DARK:xS.LIGHT,jbe={isDarkMode:!1},Ybe=Oq({name:"theme",initialState:jbe,reducers:{setIsDarkMode:(e,{payload:t})=>{e.isDarkMode=t}}}),Xbe=Ybe.reducer,Zbe=e=>ZT({palette:{mode:e,primary:{main:"#15803d"},success:{main:"#00C853"}},components:{MuiCssBaseline:{styleOverrides:{":root":{"--footer-height":"40px"}}}}}),Kbe=Zbe(window.theme||FW),qbe=(window.theme||FW)==="dark",Qbe=[{key:"method",title:"Type"},{key:"name",title:"Name"},{key:"numRequests",title:"# Requests"},{key:"numFailures",title:"# Fails"},{key:"avgResponseTime",title:"Average (ms)",round:2},{key:"minResponseTime",title:"Min (ms)"},{key:"maxResponseTime",title:"Max (ms)"},{key:"avgContentLength",title:"Average size (bytes)",round:2},{key:"totalRps",title:"RPS",round:2},{key:"totalFailPerSec",title:"Failures/s",round:2}],Jbe=Aq({reducer:W3({theme:Xbe}),preloadedState:{theme:{isDarkMode:qbe}}});function eCe({locustfile:e,showDownloadLink:t,startTime:r,endTime:n,
|
223
|
+
`,""):"No data",borderWidth:0},xAxis:{type:"time",min:(e.time||[new Date().toISOString()])[0],startValue:(e.time||[])[0],axisLabel:{formatter:Lbe}},grid:{left:60,right:40},yAxis:Ebe({splitAxis:a,yAxisLabels:o}),series:NW({charts:e,lines:r,scatterplot:s}),color:n,toolbox:{right:10,feature:{dataZoom:{title:{zoom:"Zoom Select",back:"Zoom Reset"},yAxisIndex:!1},saveAsImage:{name:t.replace(/\s+/g,"_").toLowerCase()+"_"+new Date().getTime()/1e3,title:"Download as PNG",emphasis:{iconStyle:{textPosition:"left"}}}}}}),Nbe=e=>({symbol:"none",label:{formatter:t=>`Run #${t.dataIndex+1}`,padding:[0,0,8,0]},data:(e.markers||[]).map(t=>({xAxis:t}))}),zbe=e=>t=>{const{batch:r}=t;if(!r)return;const[{start:n,startValue:i,end:a}]=r,o=n>0&&a<=100||i>0;e.setOption({dataZoom:[{type:"slider",show:o}]})},Bbe=aQ;function Fbe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s,shouldReplaceMergeLines:l=!1}){const[u,c]=V.useState(null),f=Bbe(({theme:{isDarkMode:d}})=>d),h=V.useRef(null);return V.useEffect(()=>{if(!h.current)return;const d=bce(h.current);d.setOption(Obe({charts:e,title:t,lines:r,colors:n,chartValueFormatter:i,splitAxis:a,yAxisLabels:o,scatterplot:s})),d.on("datazoom",zbe(d));const p=()=>d.resize();return window.addEventListener("resize",p),d.group="swarmCharts",Cce("swarmCharts"),c(d),()=>{Tce(d),window.removeEventListener("resize",p)}},[h]),V.useEffect(()=>{const d=r.every(({key:p})=>!!e[p]);u&&d&&u.setOption({series:r.map(({key:p,yAxisIndex:v,...g},y)=>({...g,data:e[p],...a?{yAxisIndex:v||y}:{},...y===0?{markLine:Nbe(e)}:{}}))})},[e,u,r]),V.useEffect(()=>{if(u){const{textColor:d,axisColor:p,backgroundColor:v,splitLine:g}=f?Lz.DARK:Lz.LIGHT;u.setOption({backgroundColor:v,textStyle:{color:d},title:{textStyle:{color:d}},legend:{icon:"circle",inactiveColor:d,textStyle:{color:d}},tooltip:{backgroundColor:v,textStyle:{color:d}},xAxis:{axisLine:{lineStyle:{color:p}}},yAxis:{axisLine:{lineStyle:{color:p}},splitLine:{lineStyle:{color:g}}}})}},[u,f]),V.useEffect(()=>{u&&u.setOption({series:NW({charts:e,lines:r,scatterplot:s})},l?{replaceMerge:["series"]}:void 0)},[r]),H.jsx("div",{ref:h,style:{width:"100%",height:"300px"}})}const Vbe=dl.percentilesToChart?dl.percentilesToChart.map(e=>({name:`${e*100}th percentile`,key:`responseTimePercentile${e}`})):[],Gbe=["#ff9f00","#9966CC","#8A2BE2","#8E4585","#E0B0FF","#C8A2C8","#E6E6FA"],$be=[{title:"Total Requests per Second",lines:[{name:"RPS",key:"currentRps"},{name:"Failures/s",key:"currentFailPerSec"}],colors:["#00ca5a","#ff6d6d"]},{title:"Response Times (ms)",lines:Vbe,colors:Gbe},{title:"Number of Users",lines:[{name:"Number of Users",key:"userCount"}],colors:["#0099ff"]}];function zW({charts:e}){return H.jsx("div",{children:$be.map((t,r)=>H.jsx(Fbe,{...t,charts:e},`swarm-chart-${r}`))})}const Hbe=({ui:{charts:e}})=>({charts:e});Kd(Hbe)(zW);function Wbe(e){return(e*100).toFixed(1)+"%"}function EC({classRatio:e}){return H.jsx("ul",{children:Object.entries(e).map(([t,{ratio:r,tasks:n}])=>H.jsxs("li",{children:[`${Wbe(r)} ${t}`,n&&H.jsx(EC,{classRatio:n})]},`nested-ratio-${t}`))})}function BW({ratios:{perClass:e,total:t}}){return!e&&!t?null:H.jsxs("div",{children:[e&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Ratio Per Class"}),H.jsx(EC,{classRatio:e})]}),t&&H.jsxs(H.Fragment,{children:[H.jsx("h3",{children:"Total Ratio"}),H.jsx(EC,{classRatio:t})]})]})}const Ube=({ui:{ratios:e}})=>({ratios:e});Kd(Ube)(BW);const xS={DARK:"dark",LIGHT:"light"},FW=localStorage.theme===xS.DARK||!("theme"in localStorage)&&window.matchMedia("(prefers-color-scheme: dark)").matches?xS.DARK:xS.LIGHT,jbe={isDarkMode:!1},Ybe=Oq({name:"theme",initialState:jbe,reducers:{setIsDarkMode:(e,{payload:t})=>{e.isDarkMode=t}}}),Xbe=Ybe.reducer,Zbe=e=>ZT({palette:{mode:e,primary:{main:"#15803d"},success:{main:"#00C853"}},components:{MuiCssBaseline:{styleOverrides:{":root":{"--footer-height":"40px"}}}}}),Kbe=Zbe(window.theme||FW),qbe=(window.theme||FW)==="dark",Qbe=[{key:"method",title:"Type"},{key:"name",title:"Name"},{key:"numRequests",title:"# Requests"},{key:"numFailures",title:"# Fails"},{key:"avgResponseTime",title:"Average (ms)",round:2},{key:"minResponseTime",title:"Min (ms)"},{key:"maxResponseTime",title:"Max (ms)"},{key:"avgContentLength",title:"Average size (bytes)",round:2},{key:"totalRps",title:"RPS",round:2},{key:"totalFailPerSec",title:"Failures/s",round:2}],Jbe=Aq({reducer:W3({theme:Xbe}),preloadedState:{theme:{isDarkMode:qbe}}});function eCe({locustfile:e,showDownloadLink:t,startTime:r,endTime:n,duration:i,charts:a,host:o,exceptionsStatistics:s,requestsStatistics:l,failuresStatistics:u,responseTimeStatistics:c,tasks:f}){return H.jsx(ZQ,{store:Jbe,children:H.jsxs($Y,{theme:Kbe,children:[H.jsx(TZ,{}),H.jsxs(SZ,{maxWidth:"lg",sx:{my:4},children:[H.jsxs(Nn,{sx:{display:"flex",justifyContent:"space-between",alignItems:"flex-end"},children:[H.jsx(Tr,{component:"h1",noWrap:!0,sx:{fontWeight:700},variant:"h3",children:"Locust Test Report"}),t&&H.jsx(G3,{href:`?download=1&theme=${window.theme}`,children:"Download the Report"})]}),H.jsxs(Nn,{sx:{my:2},children:[H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"During:"}),H.jsxs(Tr,{children:[IC(r)," - ",IC(n)," (",i,")"]})]}),H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"Target Host:"}),H.jsx(Tr,{children:o||"None"})]}),H.jsxs(Nn,{sx:{display:"flex",columnGap:.5},children:[H.jsx(Tr,{fontWeight:600,children:"Script:"}),H.jsx(Tr,{children:e})]})]}),H.jsxs(Nn,{sx:{display:"flex",flexDirection:"column",rowGap:4},children:[H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Request Statistics"}),H.jsx(t4,{stats:l,tableStructure:Qbe})]}),!!c.length&&H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Response Time Statistics"}),H.jsx(bne,{responseTimes:c})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Failures Statistics"}),H.jsx(QF,{errors:u})]}),!!s.length&&H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Exceptions Statistics"}),H.jsx(qF,{exceptions:s})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Charts"}),H.jsx(zW,{charts:a})]}),H.jsxs(Nn,{children:[H.jsx(Tr,{component:"h2",mb:1,noWrap:!0,variant:"h4",children:"Final ratio"}),H.jsx(BW,{ratios:f})]})]})]})]})})}const tCe=SS.createRoot(document.getElementById("root"));tCe.render(H.jsx(p9,{fallbackRender:g9,children:H.jsx(eCe,{...x9})}));
|
224
224
|
</script>
|
225
225
|
</head>
|
226
226
|
{% endraw %}
|
@@ -513,6 +513,20 @@ files = [
|
|
513
513
|
{file = "docutils-0.18.1.tar.gz", hash = "sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"},
|
514
514
|
]
|
515
515
|
|
516
|
+
[[package]]
|
517
|
+
name = "exceptiongroup"
|
518
|
+
version = "1.2.2"
|
519
|
+
description = "Backport of PEP 654 (exception groups)"
|
520
|
+
optional = false
|
521
|
+
python-versions = ">=3.7"
|
522
|
+
files = [
|
523
|
+
{file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"},
|
524
|
+
{file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"},
|
525
|
+
]
|
526
|
+
|
527
|
+
[package.extras]
|
528
|
+
test = ["pytest (>=6)"]
|
529
|
+
|
516
530
|
[[package]]
|
517
531
|
name = "filelock"
|
518
532
|
version = "3.16.1"
|
@@ -872,6 +886,17 @@ perf = ["ipython"]
|
|
872
886
|
test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
|
873
887
|
type = ["pytest-mypy"]
|
874
888
|
|
889
|
+
[[package]]
|
890
|
+
name = "iniconfig"
|
891
|
+
version = "2.0.0"
|
892
|
+
description = "brain-dead simple config-ini parsing"
|
893
|
+
optional = false
|
894
|
+
python-versions = ">=3.7"
|
895
|
+
files = [
|
896
|
+
{file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"},
|
897
|
+
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
|
898
|
+
]
|
899
|
+
|
875
900
|
[[package]]
|
876
901
|
name = "itsdangerous"
|
877
902
|
version = "2.2.0"
|
@@ -1632,6 +1657,28 @@ lxml = ">=2.1"
|
|
1632
1657
|
[package.extras]
|
1633
1658
|
test = ["pytest", "pytest-cov", "requests", "webob", "webtest"]
|
1634
1659
|
|
1660
|
+
[[package]]
|
1661
|
+
name = "pytest"
|
1662
|
+
version = "8.3.3"
|
1663
|
+
description = "pytest: simple powerful testing with Python"
|
1664
|
+
optional = false
|
1665
|
+
python-versions = ">=3.8"
|
1666
|
+
files = [
|
1667
|
+
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
|
1668
|
+
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
|
1669
|
+
]
|
1670
|
+
|
1671
|
+
[package.dependencies]
|
1672
|
+
colorama = {version = "*", markers = "sys_platform == \"win32\""}
|
1673
|
+
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
|
1674
|
+
iniconfig = "*"
|
1675
|
+
packaging = "*"
|
1676
|
+
pluggy = ">=1.5,<2"
|
1677
|
+
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
|
1678
|
+
|
1679
|
+
[package.extras]
|
1680
|
+
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
|
1681
|
+
|
1635
1682
|
[[package]]
|
1636
1683
|
name = "pywin32"
|
1637
1684
|
version = "308"
|
@@ -2474,4 +2521,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
|
|
2474
2521
|
[metadata]
|
2475
2522
|
lock-version = "2.0"
|
2476
2523
|
python-versions = ">=3.9"
|
2477
|
-
content-hash = "
|
2524
|
+
content-hash = "87823fddd6f62da6dd45b8f33d7e7cf6b707ea6c8bb6e06d1f7753d6ec6c6e83"
|
@@ -5,7 +5,7 @@ build-backend = "poetry_dynamic_versioning.backend"
|
|
5
5
|
[tool.poetry]
|
6
6
|
name = "locust"
|
7
7
|
description = "Developer-friendly load testing framework"
|
8
|
-
version = "2.32.2.
|
8
|
+
version = "2.32.2.dev34"
|
9
9
|
license = "MIT"
|
10
10
|
readme = "README.md"
|
11
11
|
authors = ["Jonatan Heyman", "Lars Holmberg"]
|
@@ -156,6 +156,7 @@ retry = "^0.9.2"
|
|
156
156
|
ruff = "0.3.7"
|
157
157
|
tox = "^4.16.0"
|
158
158
|
types-requests = "^2.32.0.20240622"
|
159
|
+
pytest = "^8.3.3"
|
159
160
|
|
160
161
|
[tool.poetry.group.docs]
|
161
162
|
optional = true
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|