locust 2.32.1.dev39__tar.gz → 2.32.2__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.
Files changed (54) hide show
  1. {locust-2.32.1.dev39 → locust-2.32.2}/PKG-INFO +1 -1
  2. {locust-2.32.1.dev39 → locust-2.32.2}/locust/_version.py +2 -2
  3. {locust-2.32.1.dev39 → locust-2.32.2}/locust/argument_parser.py +8 -0
  4. {locust-2.32.1.dev39 → locust-2.32.2}/locust/env.py +2 -0
  5. {locust-2.32.1.dev39 → locust-2.32.2}/locust/html.py +5 -2
  6. {locust-2.32.1.dev39 → locust-2.32.2}/locust/main.py +12 -4
  7. {locust-2.32.1.dev39 → locust-2.32.2}/locust/runners.py +3 -2
  8. {locust-2.32.1.dev39 → locust-2.32.2}/locust/stats.py +4 -3
  9. locust-2.32.2/locust/util/date.py +23 -0
  10. {locust-2.32.1.dev39 → locust-2.32.2}/locust/web.py +70 -27
  11. locust-2.32.1.dev39/locust/webui/dist/assets/index-LlbhTJVf.js → locust-2.32.2/locust/webui/dist/assets/index-DzzqypJO.js +52 -52
  12. {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/auth.html +1 -1
  13. {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/index.html +1 -1
  14. {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/report.html +1 -1
  15. {locust-2.32.1.dev39 → locust-2.32.2}/poetry.lock +48 -1
  16. {locust-2.32.1.dev39 → locust-2.32.2}/pyproject.toml +2 -1
  17. locust-2.32.1.dev39/locust/util/date.py +0 -5
  18. {locust-2.32.1.dev39 → locust-2.32.2}/LICENSE +0 -0
  19. {locust-2.32.1.dev39 → locust-2.32.2}/README.md +0 -0
  20. {locust-2.32.1.dev39 → locust-2.32.2}/locust/__init__.py +0 -0
  21. {locust-2.32.1.dev39 → locust-2.32.2}/locust/__main__.py +0 -0
  22. {locust-2.32.1.dev39 → locust-2.32.2}/locust/clients.py +0 -0
  23. {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/__init__.py +0 -0
  24. {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/fasthttp.py +0 -0
  25. {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/mongodb.py +0 -0
  26. {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/postgres.py +0 -0
  27. {locust-2.32.1.dev39 → locust-2.32.2}/locust/debug.py +0 -0
  28. {locust-2.32.1.dev39 → locust-2.32.2}/locust/dispatch.py +0 -0
  29. {locust-2.32.1.dev39 → locust-2.32.2}/locust/event.py +0 -0
  30. {locust-2.32.1.dev39 → locust-2.32.2}/locust/exception.py +0 -0
  31. {locust-2.32.1.dev39 → locust-2.32.2}/locust/input_events.py +0 -0
  32. {locust-2.32.1.dev39 → locust-2.32.2}/locust/log.py +0 -0
  33. {locust-2.32.1.dev39 → locust-2.32.2}/locust/py.typed +0 -0
  34. {locust-2.32.1.dev39 → locust-2.32.2}/locust/rpc/__init__.py +0 -0
  35. {locust-2.32.1.dev39 → locust-2.32.2}/locust/rpc/protocol.py +0 -0
  36. {locust-2.32.1.dev39 → locust-2.32.2}/locust/rpc/zmqrpc.py +0 -0
  37. {locust-2.32.1.dev39 → locust-2.32.2}/locust/shape.py +0 -0
  38. {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/__init__.py +0 -0
  39. {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/inspectuser.py +0 -0
  40. {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/sequential_taskset.py +0 -0
  41. {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/task.py +0 -0
  42. {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/users.py +0 -0
  43. {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/wait_time.py +0 -0
  44. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/__init__.py +0 -0
  45. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/cache.py +0 -0
  46. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/deprecation.py +0 -0
  47. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/directory.py +0 -0
  48. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/exception_handler.py +0 -0
  49. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/load_locustfile.py +0 -0
  50. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/rounding.py +0 -0
  51. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/timespan.py +0 -0
  52. {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/url.py +0 -0
  53. {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/assets/favicon-dark.png +0 -0
  54. {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/assets/favicon-light.png +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: locust
3
- Version: 2.32.1.dev39
3
+ Version: 2.32.2
4
4
  Summary: Developer-friendly load testing framework
5
5
  Home-page: https://locust.io/
6
6
  License: MIT
@@ -14,7 +14,7 @@ __version_tuple__: VERSION_TUPLE
14
14
  version_tuple: VERSION_TUPLE
15
15
 
16
16
 
17
- __version__ = "2.32.1.dev39"
17
+ __version__ = "2.32.2"
18
18
  version = __version__
19
- __version_tuple__ = (2, 32, 1, "dev39")
19
+ __version_tuple__ = (2, 32, 2)
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
@@ -52,7 +53,8 @@ def get_html_report(
52
53
  {**exc, "nodes": ", ".join(exc["nodes"])} for exc in environment.runner.exceptions.values()
53
54
  ]
54
55
 
55
- update_stats_history(environment.runner)
56
+ if stats.history and stats.history[-1]["time"] < end_time:
57
+ update_stats_history(environment.runner, end_time)
56
58
  history = stats.history
57
59
 
58
60
  is_distributed = isinstance(environment.runner, MasterRunner)
@@ -88,6 +90,7 @@ def get_html_report(
88
90
  ],
89
91
  "start_time": start_time,
90
92
  "end_time": end_time,
93
+ "duration": format_duration(stats.start_time, end_ts),
91
94
  "host": escape(str(host)),
92
95
  "history": history,
93
96
  "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("Connected to locust master: %s:%s", options.master_host, options.master_port)
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()
@@ -902,9 +902,9 @@ def sort_stats(stats: dict[Any, S]) -> list[S]:
902
902
  return [stats[key] for key in sorted(stats.keys())]
903
903
 
904
904
 
905
- def update_stats_history(runner: Runner) -> None:
905
+ def update_stats_history(runner: Runner, timestamp: str | None = None) -> None:
906
906
  stats = runner.stats
907
- timestamp = format_utc_timestamp(time.time())
907
+ timestamp = timestamp or format_utc_timestamp(time.time())
908
908
  current_response_time_percentiles = {
909
909
  f"response_time_percentile_{percentile}": [
910
910
  timestamp,
@@ -929,7 +929,8 @@ def stats_history(runner: Runner) -> None:
929
929
  while True:
930
930
  if not runner.stats.total.use_response_times_cache:
931
931
  break
932
- if runner.state != "stopped":
932
+
933
+ if runner.state != "ready" and runner.state != "stopped":
933
934
  update_stats_history(runner)
934
935
 
935
936
  gevent.sleep(HISTORY_STATS_INTERVAL_SEC)
@@ -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"
@@ -11,10 +11,11 @@ from io import StringIO
11
11
  from itertools import chain
12
12
  from json import dumps
13
13
  from time import time
14
- from typing import TYPE_CHECKING, Any
14
+ 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 format_utc_timestamp
44
+ from .util.date import format_safe_timestamp
44
45
  from .util.timespan import parse_timespan
45
46
 
46
47
  if TYPE_CHECKING:
@@ -53,6 +54,34 @@ greenlet_exception_handler = greenlet_exception_logger(logger)
53
54
  DEFAULT_CACHE_TIME = 2.0
54
55
 
55
56
 
57
+ class InputField(TypedDict, total=False):
58
+ label: str
59
+ name: str
60
+ default_value: bool | None
61
+ choices: list[str] | None
62
+ is_secret: bool | None
63
+
64
+
65
+ class CustomForm(TypedDict, total=False):
66
+ inputs: list[InputField] | None
67
+ callback_url: str
68
+ submit_button_text: str | None
69
+
70
+
71
+ class AuthProvider(TypedDict, total=False):
72
+ label: str | None
73
+ callback_url: str
74
+ icon_url: str | None
75
+
76
+
77
+ class AuthArgs(TypedDict, total=False):
78
+ custom_form: CustomForm
79
+ auth_providers: list[AuthProvider]
80
+ username_password_callback: str
81
+ error: str
82
+ info: str
83
+
84
+
56
85
  class WebUI:
57
86
  """
58
87
  Sets up and runs a Flask web app that can start and stop load tests using the
@@ -84,7 +113,7 @@ class WebUI:
84
113
  """Arguments used to render index.html for the web UI. Must be used with custom templates
85
114
  extending index.html."""
86
115
 
87
- auth_args: dict[str, Any]
116
+ auth_args: AuthArgs
88
117
  """Arguments used to render auth.html for the web UI auth page. Must be used when configuring auth"""
89
118
 
90
119
  def __init__(
@@ -92,6 +121,7 @@ class WebUI:
92
121
  environment: Environment,
93
122
  host: str,
94
123
  port: int,
124
+ web_base_path: str | None = None,
95
125
  web_login: bool = False,
96
126
  tls_cert: str | None = None,
97
127
  tls_key: str | None = None,
@@ -133,20 +163,21 @@ class WebUI:
133
163
  self.auth_args = {}
134
164
  self.app.template_folder = build_path or DEFAULT_BUILD_PATH
135
165
  self.app.static_url_path = "/assets/"
166
+
167
+ app_blueprint = Blueprint("locust", __name__, url_prefix=web_base_path)
136
168
  # ensures static js files work on Windows
137
169
  mimetypes.add_type("application/javascript", ".js")
138
-
139
170
  if self.web_login:
140
171
  self._login_manager = LoginManager()
141
172
  self._login_manager.init_app(self.app)
142
- self._login_manager.login_view = "login"
173
+ self._login_manager.login_view = "locust.login"
143
174
 
144
175
  if environment.runner:
145
176
  self.update_template_args()
146
177
  if not delayed_start:
147
178
  self.start()
148
179
 
149
- @app.errorhandler(Exception)
180
+ @app_blueprint.errorhandler(Exception)
150
181
  def handle_exception(error):
151
182
  error_message = str(error)
152
183
  error_code = getattr(error, "code", 500)
@@ -156,7 +187,7 @@ class WebUI:
156
187
  )
157
188
  return make_response(error_message, error_code)
158
189
 
159
- @app.route("/assets/<path:path>")
190
+ @app_blueprint.route("/assets/<path:path>")
160
191
  def send_assets(path):
161
192
  directory = (
162
193
  os.path.join(self.app.template_folder, "assets")
@@ -166,7 +197,7 @@ class WebUI:
166
197
 
167
198
  return send_from_directory(directory, path)
168
199
 
169
- @app.route("/")
200
+ @app_blueprint.route("/")
170
201
  @self.auth_required_if_enabled
171
202
  def index() -> str | Response:
172
203
  if not environment.runner:
@@ -175,7 +206,7 @@ class WebUI:
175
206
 
176
207
  return render_template("index.html", template_args=self.template_args)
177
208
 
178
- @app.route("/swarm", methods=["POST"])
209
+ @app_blueprint.route("/swarm", methods=["POST"])
179
210
  @self.auth_required_if_enabled
180
211
  def swarm() -> Response:
181
212
  assert request.method == "POST"
@@ -289,7 +320,7 @@ class WebUI:
289
320
  else:
290
321
  return jsonify({"success": False, "message": "No runner", "host": environment.host})
291
322
 
292
- @app.route("/stop")
323
+ @app_blueprint.route("/stop")
293
324
  @self.auth_required_if_enabled
294
325
  def stop() -> Response:
295
326
  if self._swarm_greenlet is not None:
@@ -299,7 +330,7 @@ class WebUI:
299
330
  environment.runner.stop()
300
331
  return jsonify({"success": True, "message": "Test stopped"})
301
332
 
302
- @app.route("/stats/reset")
333
+ @app_blueprint.route("/stats/reset")
303
334
  @self.auth_required_if_enabled
304
335
  def reset_stats() -> str:
305
336
  environment.events.reset_stats.fire()
@@ -308,7 +339,7 @@ class WebUI:
308
339
  environment.runner.exceptions = {}
309
340
  return "ok"
310
341
 
311
- @app.route("/stats/report")
342
+ @app_blueprint.route("/stats/report")
312
343
  @self.auth_required_if_enabled
313
344
  def stats_report() -> Response:
314
345
  theme = request.args.get("theme", "")
@@ -319,17 +350,25 @@ class WebUI:
319
350
  )
320
351
  if request.args.get("download"):
321
352
  res = app.make_response(res)
322
- res.headers["Content-Disposition"] = f"attachment;filename=report_{time()}.html"
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
+ )
323
358
  return res
324
359
 
325
360
  def _download_csv_suggest_file_name(suggest_filename_prefix: str) -> str:
326
361
  """Generate csv file download attachment filename suggestion.
327
362
 
328
363
  Arguments:
329
- suggest_filename_prefix: Prefix of the filename to suggest for saving the download. Will be appended with timestamp.
364
+ suggest_filename_prefix: Prefix of the filename to suggest for saving the download.
365
+ Will be appended with timestamp.
330
366
  """
331
-
332
- return f"{suggest_filename_prefix}_{time()}.csv"
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
+ )
333
372
 
334
373
  def _download_csv_response(csv_data: str, filename_prefix: str) -> Response:
335
374
  """Generate csv file download response with 'csv_data'.
@@ -346,7 +385,7 @@ class WebUI:
346
385
  )
347
386
  return response
348
387
 
349
- @app.route("/stats/requests/csv")
388
+ @app_blueprint.route("/stats/requests/csv")
350
389
  @self.auth_required_if_enabled
351
390
  def request_stats_csv() -> Response:
352
391
  data = StringIO()
@@ -354,7 +393,7 @@ class WebUI:
354
393
  self.stats_csv_writer.requests_csv(writer)
355
394
  return _download_csv_response(data.getvalue(), "requests")
356
395
 
357
- @app.route("/stats/requests_full_history/csv")
396
+ @app_blueprint.route("/stats/requests_full_history/csv")
358
397
  @self.auth_required_if_enabled
359
398
  def request_stats_full_history_csv() -> Response:
360
399
  options = self.environment.parsed_options
@@ -372,7 +411,7 @@ class WebUI:
372
411
 
373
412
  return make_response("Error: Server was not started with option to generate full history.", 404)
374
413
 
375
- @app.route("/stats/failures/csv")
414
+ @app_blueprint.route("/stats/failures/csv")
376
415
  @self.auth_required_if_enabled
377
416
  def failures_stats_csv() -> Response:
378
417
  data = StringIO()
@@ -380,7 +419,7 @@ class WebUI:
380
419
  self.stats_csv_writer.failures_csv(writer)
381
420
  return _download_csv_response(data.getvalue(), "failures")
382
421
 
383
- @app.route("/stats/requests")
422
+ @app_blueprint.route("/stats/requests")
384
423
  @self.auth_required_if_enabled
385
424
  @memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True)
386
425
  def request_stats() -> Response:
@@ -450,7 +489,7 @@ class WebUI:
450
489
 
451
490
  return jsonify(report)
452
491
 
453
- @app.route("/exceptions")
492
+ @app_blueprint.route("/exceptions")
454
493
  @self.auth_required_if_enabled
455
494
  def exceptions() -> Response:
456
495
  return jsonify(
@@ -467,7 +506,7 @@ class WebUI:
467
506
  }
468
507
  )
469
508
 
470
- @app.route("/exceptions/csv")
509
+ @app_blueprint.route("/exceptions/csv")
471
510
  @self.auth_required_if_enabled
472
511
  def exceptions_csv() -> Response:
473
512
  data = StringIO()
@@ -475,7 +514,7 @@ class WebUI:
475
514
  self.stats_csv_writer.exceptions_csv(writer)
476
515
  return _download_csv_response(data.getvalue(), "exceptions")
477
516
 
478
- @app.route("/tasks")
517
+ @app_blueprint.route("/tasks")
479
518
  @self.auth_required_if_enabled
480
519
  def tasks() -> dict[str, dict[str, dict[str, float]]]:
481
520
  runner = self.environment.runner
@@ -495,24 +534,25 @@ class WebUI:
495
534
  }
496
535
  return task_data
497
536
 
498
- @app.route("/logs")
537
+ @app_blueprint.route("/logs")
499
538
  @self.auth_required_if_enabled
500
539
  def logs():
501
540
  return jsonify({"master": get_logs(), "workers": self.environment.worker_logs})
502
541
 
503
- @app.route("/login")
542
+ @app_blueprint.route("/login")
504
543
  def login():
505
544
  if not self.web_login:
506
- return redirect(url_for("index"))
545
+ return redirect(url_for("locust.index"))
507
546
 
508
547
  self.auth_args["error"] = session.get("auth_error", None)
548
+ self.auth_args["info"] = session.get("auth_info", None)
509
549
 
510
550
  return render_template_from(
511
551
  "auth.html",
512
552
  auth_args=self.auth_args,
513
553
  )
514
554
 
515
- @app.route("/user", methods=["POST"])
555
+ @app_blueprint.route("/user", methods=["POST"])
516
556
  def update_user():
517
557
  assert request.method == "POST"
518
558
 
@@ -521,6 +561,8 @@ class WebUI:
521
561
 
522
562
  return {}, 201
523
563
 
564
+ app.register_blueprint(app_blueprint)
565
+
524
566
  @property
525
567
  def login_manager(self):
526
568
  if self.web_login:
@@ -577,6 +619,7 @@ class WebUI:
577
619
  if self.web_login:
578
620
  try:
579
621
  session["auth_error"] = None
622
+ session["auth_info"] = None
580
623
  return login_required(view_func)(*args, **kwargs)
581
624
  except Exception as e:
582
625
  return f"Locust auth exception: {e} See https://docs.locust.io/en/stable/extending-locust.html#adding-authentication-to-the-web-ui for configuring authentication."