diff --git a/.vscode/launch.json b/.vscode/launch.json
index 3e776b9ca0..79b88c8bc7 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -10,7 +10,7 @@
"gevent": true
},
{
- "name": "Run locust",
+ "name": "Run current locust scenario headless, 5 users",
"type": "python",
"request": "launch",
"module": "locust",
@@ -22,6 +22,18 @@
],
"console": "integratedTerminal",
"gevent": true
+ },
+ {
+ "name": "Run current locust scenario with WebUI",
+ "type": "python",
+ "request": "launch",
+ "module": "locust",
+ "args": [
+ "-f",
+ "${file}"
+ ],
+ "console": "integratedTerminal",
+ "gevent": true
}
]
}
\ No newline at end of file
diff --git a/docs/developing-locust.rst b/docs/developing-locust.rst
index 094e39a96c..efe12090e3 100644
--- a/docs/developing-locust.rst
+++ b/docs/developing-locust.rst
@@ -33,6 +33,8 @@ If you install `pre-commit `_, linting and format check
Before you open a pull request, make sure all the tests work. And if you are adding a feature, make sure it is documented (in ``docs/*.rst``).
+If you're in a hurry or don't have access to a development environment, you can simply use `Codespaces `_, the github cloud development environment. On your fork page, just click on *Code* then on *Create codespace on *, and voila, your ready to code and test.
+
Testing your changes
====================
@@ -51,6 +53,11 @@ To only run a specific suite or specific test you can call `pytest `.
-Make sure you have enabled gevent in your debugger settings. In VS Code's ``launch.json`` it looks like this:
+Make sure you have enabled gevent in your debugger settings.
+
+Debugging Locust is quite easy with Vscode:
+
+- Place breakpoints
+- Select a python file or a scenario (ex: ```examples/basic.py``)
+- Check that the Poetry virtualenv is correctly detected (bottom right)
+- Open the action *Debug using launch.json*. You will have the choice between debugging the python file, the scenario with WebUI or in headless mode
+- It could be rerun with the F5 shortkey
+
+VS Code's ``launch.json`` looks like this:
.. literalinclude:: ../.vscode/launch.json
:language: json
diff --git a/locust/html.py b/locust/html.py
index 0f8a96a9ec..62184813eb 100644
--- a/locust/html.py
+++ b/locust/html.py
@@ -12,7 +12,7 @@
from .runners import STATE_STOPPED, STATE_STOPPING, MasterRunner
from .stats import sort_stats, update_stats_history
from .user.inspectuser import get_ratio
-from .util.date import format_utc_timestamp
+from .util.date import format_duration, format_utc_timestamp
PERCENTILES_FOR_HTML_REPORT = [0.50, 0.6, 0.7, 0.8, 0.9, 0.95, 0.99, 1.0]
DEFAULT_BUILD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "webui", "dist")
@@ -88,6 +88,7 @@ def get_html_report(
],
"start_time": start_time,
"end_time": end_time,
+ "duration": format_duration(stats.start_time, stats.last_request_timestamp),
"host": escape(str(host)),
"history": history,
"show_download_link": show_download_link,
diff --git a/locust/util/date.py b/locust/util/date.py
index 20b7a26c6c..b22213de15 100644
--- a/locust/util/date.py
+++ b/locust/util/date.py
@@ -1,5 +1,194 @@
-from datetime import datetime, timezone
+import decimal
+import numbers
+import re
+from datetime import datetime, timedelta, timezone
def format_utc_timestamp(unix_timestamp):
return datetime.fromtimestamp(unix_timestamp, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def format_safe_timestamp(unix_timestamp):
+ return datetime.fromtimestamp(unix_timestamp).strftime("%Y-%m-%d-%Hh%M")
+
+
+def format_duration(start_unix_timestamp, end_unix_timestamp):
+ """
+ Format a timespan between two timestamps as a human readable string.
+ Taken from xolox/python-humanfriendly
+
+ :param start_unix_timestamp: Start timestamp.
+ :param end_unix_timestamp: End timestamp.
+
+ """
+ # Common time units, used for formatting of time spans.
+ time_units = (
+ dict(divider=1e-9, singular="nanosecond", plural="nanoseconds", abbreviations=["ns"]),
+ dict(divider=1e-6, singular="microsecond", plural="microseconds", abbreviations=["us"]),
+ dict(divider=1e-3, singular="millisecond", plural="milliseconds", abbreviations=["ms"]),
+ dict(divider=1, singular="second", plural="seconds", abbreviations=["s", "sec", "secs"]),
+ dict(divider=60, singular="minute", plural="minutes", abbreviations=["m", "min", "mins"]),
+ dict(divider=60 * 60, singular="hour", plural="hours", abbreviations=["h"]),
+ dict(divider=60 * 60 * 24, singular="day", plural="days", abbreviations=["d"]),
+ dict(divider=60 * 60 * 24 * 7, singular="week", plural="weeks", abbreviations=["w"]),
+ dict(divider=60 * 60 * 24 * 7 * 52, singular="year", plural="years", abbreviations=["y"]),
+ )
+
+ num_seconds = coerce_seconds(
+ end_unix_timestamp - start_unix_timestamp,
+ )
+ if num_seconds < 60:
+ # Fast path.
+ return pluralize(round_number(num_seconds), "second")
+ else:
+ # Slow path.
+ result = []
+ num_seconds = decimal.Decimal(str(num_seconds))
+ relevant_units = list(reversed(time_units[3:]))
+ for unit in relevant_units:
+ # Extract the unit count from the remaining time.
+ divider = decimal.Decimal(str(unit["divider"]))
+ count = num_seconds / divider
+ num_seconds %= divider
+ # Round the unit count appropriately.
+ if unit != relevant_units[-1]:
+ # Integer rounding for all but the smallest unit.
+ count = int(count)
+ else:
+ # Floating point rounding for the smallest unit.
+ count = round_number(count)
+ # Only include relevant units in the result.
+ if count not in (0, "0"):
+ result.append(pluralize(count, unit["singular"], unit["plural"]))
+ if len(result) == 1:
+ # A single count/unit combination.
+ return result[0]
+ else:
+ # Format the timespan in a readable way.
+ return concatenate(result[:3])
+
+
+def coerce_seconds(value):
+ """
+ Coerce a value to the number of seconds.
+
+ :param value: An :class:`int`, :class:`float` or
+ :class:`datetime.timedelta` object.
+ :returns: An :class:`int` or :class:`float` value.
+
+ When `value` is a :class:`datetime.timedelta` object the
+ :meth:`~datetime.timedelta.total_seconds()` method is called.
+ """
+ if isinstance(value, timedelta):
+ return value.total_seconds()
+ if not isinstance(value, numbers.Number):
+ msg = "Failed to coerce value to number of seconds! (%r)"
+ raise ValueError(format(msg, value))
+ return value
+
+
+def round_number(count, keep_width=False):
+ """
+ Round a floating point number to two decimal places in a human friendly format.
+
+ :param count: The number to format.
+ :param keep_width: :data:`True` if trailing zeros should not be stripped,
+ :data:`False` if they can be stripped.
+ :returns: The formatted number as a string. If no decimal places are
+ required to represent the number, they will be omitted.
+
+ The main purpose of this function is to be used by functions like
+ :func:`format_length()`, :func:`format_size()` and
+ :func:`format_timespan()`.
+
+ Here are some examples:
+
+ >>> from humanfriendly import round_number
+ >>> round_number(1)
+ '1'
+ >>> round_number(math.pi)
+ '3.14'
+ >>> round_number(5.001)
+ '5'
+ """
+ text = "%.2f" % float(count)
+ if not keep_width:
+ text = re.sub("0+$", "", text)
+ text = re.sub(r"\.$", "", text)
+ return text
+
+
+def concatenate(items, conjunction="and", serial_comma=False):
+ """
+ Concatenate a list of items in a human friendly way.
+
+ :param items:
+
+ A sequence of strings.
+
+ :param conjunction:
+
+ The word to use before the last item (a string, defaults to "and").
+
+ :param serial_comma:
+
+ :data:`True` to use a `serial comma`_, :data:`False` otherwise
+ (defaults to :data:`False`).
+
+ :returns:
+
+ A single string.
+
+ >>> from humanfriendly.text import concatenate
+ >>> concatenate(["eggs", "milk", "bread"])
+ 'eggs, milk and bread'
+
+ .. _serial comma: https://en.wikipedia.org/wiki/Serial_comma
+ """
+ items = list(items)
+ if len(items) > 1:
+ final_item = items.pop()
+ formatted = ", ".join(items)
+ if serial_comma:
+ formatted += ","
+ return " ".join([formatted, conjunction, final_item])
+ elif items:
+ return items[0]
+ else:
+ return ""
+
+
+def pluralize(count, singular, plural=None):
+ """
+ Combine a count with the singular or plural form of a word.
+
+ :param count: The count (a number).
+ :param singular: The singular form of the word (a string).
+ :param plural: The plural form of the word (a string or :data:`None`).
+ :returns: The count and singular or plural word concatenated (a string).
+
+ See :func:`pluralize_raw()` for the logic underneath :func:`pluralize()`.
+ """
+ return "%s %s" % (count, pluralize_raw(count, singular, plural))
+
+
+def pluralize_raw(count, singular, plural=None):
+ """
+ Select the singular or plural form of a word based on a count.
+
+ :param count: The count (a number).
+ :param singular: The singular form of the word (a string).
+ :param plural: The plural form of the word (a string or :data:`None`).
+ :returns: The singular or plural form of the word (a string).
+
+ When the given count is exactly 1.0 the singular form of the word is
+ selected, in all other cases the plural form of the word is selected.
+
+ If the plural form of the word is not provided it is obtained by
+ concatenating the singular form of the word with the letter "s". Of course
+ this will not always be correct, which is why you have the option to
+ specify both forms.
+ """
+ if not plural:
+ plural = singular + "s"
+ return singular if float(count) == 1.0 else plural
diff --git a/locust/web.py b/locust/web.py
index 365665539c..1be9d46622 100644
--- a/locust/web.py
+++ b/locust/web.py
@@ -10,7 +10,6 @@
from io import StringIO
from itertools import chain
from json import dumps
-from time import time
from typing import TYPE_CHECKING, Any
import gevent
@@ -40,7 +39,7 @@
from .stats import StatsCSV, StatsCSVFileWriter, StatsErrorDict, sort_stats
from .user.inspectuser import get_ratio
from .util.cache import memoize
-from .util.date import format_utc_timestamp
+from .util.date import format_safe_timestamp
from .util.timespan import parse_timespan
if TYPE_CHECKING:
@@ -319,17 +318,25 @@ def stats_report() -> Response:
)
if request.args.get("download"):
res = app.make_response(res)
- res.headers["Content-Disposition"] = f"attachment;filename=report_{time()}.html"
+ host = f"_{self.environment.host}" if self.environment.host else ""
+ res.headers["Content-Disposition"] = (
+ f"attachment;filename=Locust_{format_safe_timestamp(self.environment.stats.start_time)}_"
+ + f"{self.environment.locustfile}{host}.html"
+ )
return res
def _download_csv_suggest_file_name(suggest_filename_prefix: str) -> str:
"""Generate csv file download attachment filename suggestion.
Arguments:
- suggest_filename_prefix: Prefix of the filename to suggest for saving the download. Will be appended with timestamp.
+ suggest_filename_prefix: Prefix of the filename to suggest for saving the download.
+ Will be appended with timestamp.
"""
-
- return f"{suggest_filename_prefix}_{time()}.csv"
+ host = f"_{self.environment.host}" if self.environment.host else ""
+ return (
+ f"Locust_{format_safe_timestamp(self.environment.stats.start_time)}_"
+ + f"{self.environment.locustfile}{host}_{suggest_filename_prefix}.csv"
+ )
def _download_csv_response(csv_data: str, filename_prefix: str) -> Response:
"""Generate csv file download response with 'csv_data'.
diff --git a/locust/webui/src/pages/HtmlReport.tsx b/locust/webui/src/pages/HtmlReport.tsx
index 4d7155143c..05520bee05 100644
--- a/locust/webui/src/pages/HtmlReport.tsx
+++ b/locust/webui/src/pages/HtmlReport.tsx
@@ -42,6 +42,7 @@ export default function HtmlReport({
showDownloadLink,
startTime,
endTime,
+ duration,
charts,
host,
exceptionsStatistics,
@@ -75,7 +76,7 @@ export default function HtmlReport({
During:
- {formatLocaleString(startTime)} - {formatLocaleString(endTime)}
+ {formatLocaleString(startTime)} - {formatLocaleString(endTime)} ({duration})
diff --git a/locust/webui/src/test/mocks/swarmState.mock.ts b/locust/webui/src/test/mocks/swarmState.mock.ts
index 92caac14a1..e22699894a 100644
--- a/locust/webui/src/test/mocks/swarmState.mock.ts
+++ b/locust/webui/src/test/mocks/swarmState.mock.ts
@@ -36,6 +36,7 @@ export const swarmReportMock: IReport = {
showDownloadLink: true,
startTime: '2024-02-26 12:13:26',
endTime: '2024-02-26 12:13:26',
+ duration: '0 seconds',
host: 'http://0.0.0.0:8089/',
exceptionsStatistics: [],
requestsStatistics: [],
diff --git a/locust/webui/src/types/swarm.types.ts b/locust/webui/src/types/swarm.types.ts
index 08a4b14add..7c83fd8f0b 100644
--- a/locust/webui/src/types/swarm.types.ts
+++ b/locust/webui/src/types/swarm.types.ts
@@ -71,6 +71,7 @@ export interface IReport {
showDownloadLink: boolean;
startTime: string;
endTime: string;
+ duration: string;
host: string;
charts: ICharts;
requestsStatistics: ISwarmStat[];