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.
- {locust-2.32.1.dev39 → locust-2.32.2}/PKG-INFO +1 -1
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/_version.py +2 -2
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/argument_parser.py +8 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/env.py +2 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/html.py +5 -2
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/main.py +12 -4
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/runners.py +3 -2
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/stats.py +4 -3
- locust-2.32.2/locust/util/date.py +23 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/web.py +70 -27
- 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
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/auth.html +1 -1
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/index.html +1 -1
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/report.html +1 -1
- {locust-2.32.1.dev39 → locust-2.32.2}/poetry.lock +48 -1
- {locust-2.32.1.dev39 → locust-2.32.2}/pyproject.toml +2 -1
- locust-2.32.1.dev39/locust/util/date.py +0 -5
- {locust-2.32.1.dev39 → locust-2.32.2}/LICENSE +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/README.md +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/__init__.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/__main__.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/clients.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/__init__.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/fasthttp.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/mongodb.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/contrib/postgres.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/debug.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/dispatch.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/event.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/exception.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/input_events.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/log.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/py.typed +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/rpc/__init__.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/rpc/protocol.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/rpc/zmqrpc.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/shape.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/__init__.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/inspectuser.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/sequential_taskset.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/task.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/users.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/user/wait_time.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/__init__.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/cache.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/deprecation.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/directory.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/exception_handler.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/load_locustfile.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/rounding.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/timespan.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/util/url.py +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/assets/favicon-dark.png +0 -0
- {locust-2.32.1.dev39 → locust-2.32.2}/locust/webui/dist/assets/favicon-light.png +0 -0
@@ -14,7 +14,7 @@ __version_tuple__: VERSION_TUPLE
|
|
14
14
|
version_tuple: VERSION_TUPLE
|
15
15
|
|
16
16
|
|
17
|
-
__version__ = "2.32.
|
17
|
+
__version__ = "2.32.2"
|
18
18
|
version = __version__
|
19
|
-
__version_tuple__ = (2, 32,
|
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
|
-
|
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(
|
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
|
-
|
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
|
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:
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
|
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.
|
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
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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
|
-
@
|
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."
|