def delete(self, surveyName=None): # pylint: disable=no-value-for-parameter # basic auth protected route if basic auth is enabled if current_app.config.get("BASIC_AUTH_ENABLED"): print("basic_auth_enabled") auth = BasicAuth() auth_valid = auth.authenticate() print(auth_valid) if auth_valid is None or not auth_valid: return auth.challenge() payload = request.get_json() if surveyName is None: if payload is None or not bool(payload): return error_response( PAYLOAD_MISSING_OR_EMPTY, PAYLOAD_MUST_BE_SENT.format(resource="Surveys")), 400 elif surveyName is not None: payload["surveyName"] = surveyName surveyToDelete = payload.get("surveyName") if surveyToDelete is None: return error_response( API_PARAMETER_MISSING, API_PARAMETER_MUST_BE_PROVIDED.format( resource="Surveys", parameter="surveyName")), 400 try: SurveyCRUD().delete_survey(surveyName=surveyToDelete) return {"surveyName": surveyToDelete}, 200 except InternalError as e: return error_response(INTERNAL_ERROR, str(e)), 500
class WebUI: """ Sets up and runs a Flask web that can start and stop load tests using the :attr:`environment.runner <locust.env.Environment.runner>` as well as show the load test statistics in :attr:`environment.stats <locust.env.Environment.stats>` """ app = None """ Reference to the :class:`flask.Flask` app. Can be used to add additional web routes and customize the Flask app in other various ways. Example:: from flask import request @web_ui.app.route("/my_custom_route") def my_custom_route(): return "your IP is: %s" % request.remote_addr """ greenlet = None """ Greenlet of the running web server """ server = None """Reference to the :class:`pyqsgi.WSGIServer` instance""" template_args: dict = None """Arguments used to render index.html for the web UI. Must be used with custom templates extending index.html.""" def __init__( self, environment, host, port, auth_credentials=None, tls_cert=None, tls_key=None, stats_csv_writer=None, delayed_start=False, ): """ Create WebUI instance and start running the web server in a separate greenlet (self.greenlet) Arguments: environment: Reference to the current Locust Environment host: Host/interface that the web server should accept connections to port: Port that the web server should listen to auth_credentials: If provided, it will enable basic auth with all the routes protected by default. Should be supplied in the format: "user:pass". tls_cert: A path to a TLS certificate tls_key: A path to a TLS private key delayed_start: Whether or not to delay starting web UI until `start()` is called. Delaying web UI start allows for adding Flask routes or Blueprints before accepting requests, avoiding errors. """ environment.web_ui = self self.stats_csv_writer = stats_csv_writer or StatsCSV(environment, stats_module.PERCENTILES_TO_REPORT) self.environment = environment self.host = host self.port = port self.tls_cert = tls_cert self.tls_key = tls_key app = Flask(__name__) self.app = app app.debug = True app.root_path = os.path.dirname(os.path.abspath(__file__)) self.app.config["BASIC_AUTH_ENABLED"] = False self.auth = None self.greenlet = None if auth_credentials is not None: credentials = auth_credentials.split(":") if len(credentials) == 2: self.app.config["BASIC_AUTH_USERNAME"] = credentials[0] self.app.config["BASIC_AUTH_PASSWORD"] = credentials[1] self.app.config["BASIC_AUTH_ENABLED"] = True self.auth = BasicAuth() self.auth.init_app(self.app) else: raise AuthCredentialsError( "Invalid auth_credentials. It should be a string in the following format: 'user.pass'" ) if environment.runner: self.update_template_args() if not delayed_start: self.start() @app.route("/") @self.auth_required_if_enabled def index(): if not environment.runner: return make_response("Error: Locust Environment does not have any runner", 500) self.update_template_args() return render_template("index.html", **self.template_args) @app.route("/swarm", methods=["POST"]) @self.auth_required_if_enabled def swarm(): assert request.method == "POST" user_count = int(request.form["user_count"]) spawn_rate = float(request.form["spawn_rate"]) if request.form.get("host"): environment.host = str(request.form["host"]) if environment.step_load: step_user_count = int(request.form["step_user_count"]) step_duration = parse_timespan(str(request.form["step_duration"])) environment.runner.start_stepload(user_count, spawn_rate, step_user_count, step_duration) return jsonify( {"success": True, "message": "Swarming started in Step Load Mode", "host": environment.host} ) if environment.shape_class: environment.runner.start_shape() return jsonify( {"success": True, "message": "Swarming started using shape class", "host": environment.host} ) environment.runner.start(user_count, spawn_rate) return jsonify({"success": True, "message": "Swarming started", "host": environment.host}) @app.route("/stop") @self.auth_required_if_enabled def stop(): environment.runner.stop() return jsonify({"success": True, "message": "Test stopped"}) @app.route("/stats/reset") @self.auth_required_if_enabled def reset_stats(): environment.events.reset_stats.fire() environment.runner.stats.reset_all() environment.runner.exceptions = {} return "ok" @app.route("/stats/report") @self.auth_required_if_enabled def stats_report(): stats = self.environment.runner.stats start_ts = stats.start_time start_time = datetime.datetime.fromtimestamp(start_ts) start_time = start_time.strftime("%Y-%m-%d %H:%M:%S") end_ts = stats.last_request_timestamp end_time = datetime.datetime.fromtimestamp(end_ts) end_time = end_time.strftime("%Y-%m-%d %H:%M:%S") host = None if environment.host: host = environment.host elif environment.runner.user_classes: all_hosts = set([l.host for l in environment.runner.user_classes]) if len(all_hosts) == 1: host = list(all_hosts)[0] requests_statistics = list(chain(sort_stats(stats.entries), [stats.total])) failures_statistics = sort_stats(stats.errors) exceptions_statistics = [] for exc in environment.runner.exceptions.values(): exc["nodes"] = ", ".join(exc["nodes"]) exceptions_statistics.append(exc) history = stats.history static_js = "" js_files = ["jquery-1.11.3.min.js", "echarts.common.min.js", "vintage.js", "chart.js"] for js_file in js_files: path = os.path.join(os.path.dirname(__file__), "static", js_file) with open(path, encoding="utf8") as f: content = f.read() static_js += "// " + js_file + "\n" static_js += content static_js += "\n\n\n" res = render_template( "report.html", int=int, round=round, requests_statistics=requests_statistics, failures_statistics=failures_statistics, exceptions_statistics=exceptions_statistics, start_time=start_time, end_time=end_time, host=host, history=history, static_js=static_js, ) if request.args.get("download"): res = app.make_response(res) res.headers["Content-Disposition"] = "attachment;filename=report_%s.html" % time() return res def _download_csv_suggest_file_name(suggest_filename_prefix): """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. """ return f"{suggest_filename_prefix}_{time()}.csv" def _download_csv_response(csv_data, filename_prefix): """Generate csv file download response with 'csv_data'. Arguments: csv_data: CSV header and data rows. filename_prefix: Prefix of the filename to suggest for saving the download. Will be appended with timestamp. """ response = make_response(csv_data) response.headers["Content-type"] = "text/csv" response.headers[ "Content-disposition" ] = f"attachment;filename={_download_csv_suggest_file_name(filename_prefix)}" return response @app.route("/stats/requests/csv") @self.auth_required_if_enabled def request_stats_csv(): data = StringIO() writer = csv.writer(data) self.stats_csv_writer.requests_csv(writer) return _download_csv_response(data.getvalue(), "requests") @app.route("/stats/requests_full_history/csv") @self.auth_required_if_enabled def request_stats_full_history_csv(): options = self.environment.parsed_options if options and options.stats_history_enabled: return send_file( os.path.abspath(self.stats_csv_writer.stats_history_file_name()), mimetype="text/csv", as_attachment=True, attachment_filename=_download_csv_suggest_file_name("requests_full_history"), add_etags=True, cache_timeout=None, conditional=True, last_modified=None, ) return make_response("Error: Server was not started with option to generate full history.", 404) @app.route("/stats/failures/csv") @self.auth_required_if_enabled def failures_stats_csv(): data = StringIO() writer = csv.writer(data) self.stats_csv_writer.failures_csv(writer) return _download_csv_response(data.getvalue(), "failures") @app.route("/stats/requests") @self.auth_required_if_enabled @memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True) def request_stats(): stats = [] for s in chain(sort_stats(self.environment.runner.stats.entries), [environment.runner.stats.total]): stats.append( { "method": s.method, "name": s.name, "safe_name": escape(s.name, quote=False), "num_requests": s.num_requests, "num_failures": s.num_failures, "avg_response_time": s.avg_response_time, "min_response_time": 0 if s.min_response_time is None else proper_round(s.min_response_time), "max_response_time": proper_round(s.max_response_time), "current_rps": s.current_rps, "current_fail_per_sec": s.current_fail_per_sec, "median_response_time": s.median_response_time, "ninetieth_response_time": s.get_response_time_percentile(0.9), "avg_content_length": s.avg_content_length, } ) errors = [] for e in environment.runner.errors.values(): err_dict = e.to_dict() err_dict["name"] = escape(err_dict["name"]) err_dict["error"] = escape(err_dict["error"]) errors.append(err_dict) # Truncate the total number of stats and errors displayed since a large number of rows will cause the app # to render extremely slowly. Aggregate stats should be preserved. report = {"stats": stats[:500], "errors": errors[:500]} if len(stats) > 500: report["stats"] += [stats[-1]] if stats: report["total_rps"] = stats[len(stats) - 1]["current_rps"] report["fail_ratio"] = environment.runner.stats.total.fail_ratio report[ "current_response_time_percentile_95" ] = environment.runner.stats.total.get_current_response_time_percentile(0.95) report[ "current_response_time_percentile_50" ] = environment.runner.stats.total.get_current_response_time_percentile(0.5) is_distributed = isinstance(environment.runner, MasterRunner) if is_distributed: workers = [] for worker in environment.runner.clients.values(): workers.append( { "id": worker.id, "state": worker.state, "user_count": worker.user_count, "cpu_usage": worker.cpu_usage, } ) report["workers"] = workers report["state"] = environment.runner.state report["user_count"] = environment.runner.user_count return jsonify(report) @app.route("/exceptions") @self.auth_required_if_enabled def exceptions(): return jsonify( { "exceptions": [ { "count": row["count"], "msg": row["msg"], "traceback": row["traceback"], "nodes": ", ".join(row["nodes"]), } for row in environment.runner.exceptions.values() ] } ) @app.route("/exceptions/csv") @self.auth_required_if_enabled def exceptions_csv(): data = StringIO() writer = csv.writer(data) writer.writerow(["Count", "Message", "Traceback", "Nodes"]) for exc in environment.runner.exceptions.values(): nodes = ", ".join(exc["nodes"]) writer.writerow([exc["count"], exc["msg"], exc["traceback"], nodes]) return _download_csv_response(data.getvalue(), "exceptions") def start(self): self.greenlet = gevent.spawn(self.start_server) self.greenlet.link_exception(greenlet_exception_handler) def start_server(self): if self.tls_cert and self.tls_key: self.server = pywsgi.WSGIServer( (self.host, self.port), self.app, log=None, keyfile=self.tls_key, certfile=self.tls_cert ) else: self.server = pywsgi.WSGIServer((self.host, self.port), self.app, log=None) self.server.serve_forever() def stop(self): """ Stop the running web server """ self.server.stop() def auth_required_if_enabled(self, view_func): """ Decorator that can be used on custom route methods that will turn on Basic Auth authentication if the ``--web-auth`` flag is used. Example:: @web_ui.app.route("/my_custom_route") @web_ui.auth_required_if_enabled def my_custom_route(): return "custom response" """ @wraps(view_func) def wrapper(*args, **kwargs): if self.app.config["BASIC_AUTH_ENABLED"]: if self.auth.authenticate(): return view_func(*args, **kwargs) else: return self.auth.challenge() else: return view_func(*args, **kwargs) return wrapper def update_template_args(self): override_host_warning = False if self.environment.host: host = self.environment.host elif self.environment.runner.user_classes: all_hosts = set([l.host for l in self.environment.runner.user_classes]) if len(all_hosts) == 1: host = list(all_hosts)[0] else: # since we have multiple User classes with different host attributes, we'll # inform that specifying host will override the host for all User classes override_host_warning = True host = None else: host = None options = self.environment.parsed_options is_distributed = isinstance(self.environment.runner, MasterRunner) if is_distributed: worker_count = self.environment.runner.worker_count else: worker_count = 0 self.template_args = { "state": self.environment.runner.state, "is_distributed": is_distributed, "user_count": self.environment.runner.user_count, "version": version, "host": host, "override_host_warning": override_host_warning, "num_users": options and options.num_users, "spawn_rate": options and options.spawn_rate, "step_users": options and options.step_users, "step_time": options and options.step_time, "worker_count": worker_count, "is_step_load": self.environment.step_load, "is_shape": self.environment.shape_class, "stats_history_enabled": options and options.stats_history_enabled, }
class WebUI: """ Sets up and runs a Flask web app that can start and stop load tests using the :attr:`environment.runner <locust.env.Environment.runner>` as well as show the load test statistics in :attr:`environment.stats <locust.env.Environment.stats>` """ app = None """ Reference to the :class:`flask.Flask` app. Can be used to add additional web routes and customize the Flask app in other various ways. Example:: from flask import request @web_ui.app.route("/my_custom_route") def my_custom_route(): return "your IP is: %s" % request.remote_addr """ greenlet = None """ Greenlet of the running web server """ server = None """Reference to the :class:`pyqsgi.WSGIServer` instance""" def __init__(self, environment, host, port, auth_credentials=None, tls_cert=None, tls_key=None): """ Create WebUI instance and start running the web server in a separate greenlet (self.greenlet) Arguments: environment: Reference to the curren Locust Environment host: Host/interface that the web server should accept connections to port: Port that the web server should listen to auth_credentials: If provided, it will enable basic auth with all the routes protected by default. Should be supplied in the format: "user:pass". tls_cert: A path to a TLS certificate tls_key: A path to a TLS private key """ environment.web_ui = self self.environment = environment self.host = host self.port = port self.tls_cert = tls_cert self.tls_key = tls_key app = Flask(__name__) self.app = app app.debug = True app.root_path = os.path.dirname(os.path.abspath(__file__)) self.app.config["BASIC_AUTH_ENABLED"] = False self.auth = None self.greenlet = None if auth_credentials is not None: credentials = auth_credentials.split(':') if len(credentials) == 2: self.app.config["BASIC_AUTH_USERNAME"] = credentials[0] self.app.config["BASIC_AUTH_PASSWORD"] = credentials[1] self.app.config["BASIC_AUTH_ENABLED"] = True self.auth = BasicAuth() self.auth.init_app(self.app) else: raise AuthCredentialsError( "Invalid auth_credentials. It should be a string in the following format: 'user.pass'" ) @app.route('/') @self.auth_required_if_enabled def index(): if not environment.runner: return make_response( "Error: Locust Environment does not have any runner", 500) is_distributed = isinstance(environment.runner, MasterRunner) if is_distributed: worker_count = environment.runner.worker_count else: worker_count = 0 override_host_warning = False if environment.host: host = environment.host elif environment.runner.user_classes: all_hosts = set( [l.host for l in environment.runner.user_classes]) if len(all_hosts) == 1: host = list(all_hosts)[0] else: # since we have mulitple User classes with different host attributes, we'll # inform that specifying host will override the host for all User classes override_host_warning = True host = None else: host = None options = environment.parsed_options return render_template( "index.html", state=environment.runner.state, is_distributed=is_distributed, user_count=environment.runner.user_count, version=version, host=host, override_host_warning=override_host_warning, num_users=options and options.num_users, hatch_rate=options and options.hatch_rate, step_users=options and options.step_users, step_time=options and options.step_time, worker_count=worker_count, is_step_load=environment.step_load, ) @app.route('/swarm', methods=["POST"]) @self.auth_required_if_enabled def swarm(): assert request.method == "POST" user_count = int(request.form["user_count"]) hatch_rate = float(request.form["hatch_rate"]) if (request.form.get("host")): environment.host = str(request.form["host"]) if environment.step_load: step_user_count = int(request.form["step_user_count"]) step_duration = parse_timespan( str(request.form["step_duration"])) environment.runner.start_stepload(user_count, hatch_rate, step_user_count, step_duration) return jsonify({ 'success': True, 'message': 'Swarming started in Step Load Mode', 'host': environment.host }) environment.runner.start(user_count, hatch_rate) return jsonify({ 'success': True, 'message': 'Swarming started', 'host': environment.host }) @app.route('/stop') @self.auth_required_if_enabled def stop(): environment.runner.stop() return jsonify({'success': True, 'message': 'Test stopped'}) @app.route("/stats/reset") @self.auth_required_if_enabled def reset_stats(): environment.runner.stats.reset_all() environment.runner.exceptions = {} return "ok" @app.route("/stats/requests/csv") @self.auth_required_if_enabled def request_stats_csv(): data = StringIO() writer = csv.writer(data) requests_csv(self.environment.runner.stats, writer) response = make_response(data.getvalue()) file_name = "requests_{0}.csv".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/csv" response.headers["Content-disposition"] = disposition return response @app.route("/stats/failures/csv") @self.auth_required_if_enabled def failures_stats_csv(): data = StringIO() writer = csv.writer(data) failures_csv(self.environment.runner.stats, writer) response = make_response(data.getvalue()) file_name = "failures_{0}.csv".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/csv" response.headers["Content-disposition"] = disposition return response @app.route('/stats/requests') @self.auth_required_if_enabled @memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True) def request_stats(): stats = [] for s in chain(sort_stats(self.environment.runner.stats.entries), [environment.runner.stats.total]): stats.append({ "method": s.method, "name": s.name, "safe_name": escape(s.name, quote=False), "num_requests": s.num_requests, "num_failures": s.num_failures, "avg_response_time": s.avg_response_time, "min_response_time": 0 if s.min_response_time is None else proper_round( s.min_response_time), "max_response_time": proper_round(s.max_response_time), "current_rps": s.current_rps, "current_fail_per_sec": s.current_fail_per_sec, "median_response_time": s.median_response_time, "ninetieth_response_time": s.get_response_time_percentile(0.9), "avg_content_length": s.avg_content_length, }) errors = [] for e in environment.runner.errors.values(): err_dict = e.to_dict() err_dict["name"] = escape(err_dict["name"]) err_dict["error"] = escape(err_dict["error"]) errors.append(err_dict) # Truncate the total number of stats and errors displayed since a large number of rows will cause the app # to render extremely slowly. Aggregate stats should be preserved. report = {"stats": stats[:500], "errors": errors[:500]} if len(stats) > 500: report["stats"] += [stats[-1]] if stats: report["total_rps"] = stats[len(stats) - 1]["current_rps"] report[ "fail_ratio"] = environment.runner.stats.total.fail_ratio report[ "current_response_time_percentile_95"] = environment.runner.stats.total.get_current_response_time_percentile( 0.95) report[ "current_response_time_percentile_50"] = environment.runner.stats.total.get_current_response_time_percentile( 0.5) is_distributed = isinstance(environment.runner, MasterRunner) if is_distributed: workers = [] for worker in environment.runner.clients.values(): workers.append({ "id": worker.id, "state": worker.state, "user_count": worker.user_count, "cpu_usage": worker.cpu_usage }) report["workers"] = workers report["state"] = environment.runner.state report["user_count"] = environment.runner.user_count return jsonify(report) @app.route("/exceptions") @self.auth_required_if_enabled def exceptions(): return jsonify({ 'exceptions': [{ "count": row["count"], "msg": row["msg"], "traceback": row["traceback"], "nodes": ", ".join(row["nodes"]) } for row in environment.runner.exceptions.values()] }) @app.route("/exceptions/csv") @self.auth_required_if_enabled def exceptions_csv(): data = StringIO() writer = csv.writer(data) writer.writerow(["Count", "Message", "Traceback", "Nodes"]) for exc in environment.runner.exceptions.values(): nodes = ", ".join(exc["nodes"]) writer.writerow( [exc["count"], exc["msg"], exc["traceback"], nodes]) response = make_response(data.getvalue()) file_name = "exceptions_{0}.csv".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/csv" response.headers["Content-disposition"] = disposition return response # start the web server self.greenlet = gevent.spawn(self.start) self.greenlet.link_exception(greenlet_exception_handler) def start(self): if self.tls_cert and self.tls_key: self.server = pywsgi.WSGIServer((self.host, self.port), self.app, log=None, keyfile=self.tls_key, certfile=self.tls_cert) else: self.server = pywsgi.WSGIServer((self.host, self.port), self.app, log=None) self.server.serve_forever() def stop(self): """ Stop the running web server """ self.server.stop() def auth_required_if_enabled(self, view_func): """ Decorator that can be used on custom route methods that will turn on Basic Auth authentication if the ``--web-auth`` flag is used. Example:: @web_ui.app.route("/my_custom_route") @web_ui.auth_required_if_enabled def my_custom_route(): return "custom response" """ @wraps(view_func) def wrapper(*args, **kwargs): if self.app.config["BASIC_AUTH_ENABLED"]: if self.auth.authenticate(): return view_func(*args, **kwargs) else: return self.auth.challenge() else: return view_func(*args, **kwargs) return wrapper
class WebUI: server = None """Reference to pyqsgi.WSGIServer once it's started""" def __init__(self, environment, auth_credentials=None): """ If auth_credentials is provided, it will enable basic auth with all the routes protected by default. Should be supplied in the format: "user:pass". """ environment.web_ui = self self.environment = environment app = Flask(__name__) self.app = app app.debug = True app.root_path = os.path.dirname(os.path.abspath(__file__)) self.app.config["BASIC_AUTH_ENABLED"] = False self.auth = None if auth_credentials is not None: credentials = auth_credentials.split(':') if len(credentials) == 2: self.app.config["BASIC_AUTH_USERNAME"] = credentials[0] self.app.config["BASIC_AUTH_PASSWORD"] = credentials[1] self.app.config["BASIC_AUTH_ENABLED"] = True self.auth = BasicAuth() self.auth.init_app(self.app) else: raise AuthCredentialsError( "Invalid auth_credentials. It should be a string in the following format: 'user.pass'" ) @app.route('/') @self.auth_required_if_enabled def index(): if not environment.runner: return make_response( "Error: Locust Environment does not have any runner", 500) is_distributed = isinstance(environment.runner, MasterLocustRunner) if is_distributed: worker_count = environment.runner.worker_count else: worker_count = 0 override_host_warning = False if environment.host: host = environment.host elif environment.runner.locust_classes: all_hosts = set( [l.host for l in environment.runner.locust_classes]) if len(all_hosts) == 1: host = list(all_hosts)[0] else: # since we have mulitple Locust classes with different host attributes, we'll # inform that specifying host will override the host for all Locust classes override_host_warning = True host = None else: host = None return render_template( "index.html", state=environment.runner.state, is_distributed=is_distributed, user_count=environment.runner.user_count, version=version, host=host, override_host_warning=override_host_warning, worker_count=worker_count, is_step_load=environment.step_load, ) @app.route('/swarm', methods=["POST"]) @self.auth_required_if_enabled def swarm(): assert request.method == "POST" locust_count = int(request.form["locust_count"]) hatch_rate = float(request.form["hatch_rate"]) if (request.form.get("host")): environment.host = str(request.form["host"]) if environment.step_load: step_locust_count = int(request.form["step_locust_count"]) step_duration = parse_timespan( str(request.form["step_duration"])) environment.runner.start_stepload(locust_count, hatch_rate, step_locust_count, step_duration) return jsonify({ 'success': True, 'message': 'Swarming started in Step Load Mode', 'host': environment.host }) environment.runner.start(locust_count, hatch_rate) return jsonify({ 'success': True, 'message': 'Swarming started', 'host': environment.host }) @app.route('/stop') @self.auth_required_if_enabled def stop(): environment.runner.stop() return jsonify({'success': True, 'message': 'Test stopped'}) @app.route("/stats/reset") @self.auth_required_if_enabled def reset_stats(): environment.runner.stats.reset_all() environment.runner.exceptions = {} return "ok" @app.route("/stats/requests/csv") @self.auth_required_if_enabled def request_stats_csv(): response = make_response( requests_csv(self.environment.runner.stats)) file_name = "requests_{0}.csv".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/csv" response.headers["Content-disposition"] = disposition return response @app.route("/stats/failures/csv") @self.auth_required_if_enabled def failures_stats_csv(): response = make_response( failures_csv(self.environment.runner.stats)) file_name = "failures_{0}.csv".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/csv" response.headers["Content-disposition"] = disposition return response @app.route('/stats/requests') @self.auth_required_if_enabled @memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True) def request_stats(): stats = [] for s in chain(sort_stats(self.environment.runner.stats.entries), [environment.runner.stats.total]): stats.append({ "method": s.method, "name": s.name, "safe_name": escape(s.name, quote=False), "num_requests": s.num_requests, "num_failures": s.num_failures, "avg_response_time": s.avg_response_time, "min_response_time": 0 if s.min_response_time is None else proper_round( s.min_response_time), "max_response_time": proper_round(s.max_response_time), "current_rps": s.current_rps, "current_fail_per_sec": s.current_fail_per_sec, "median_response_time": s.median_response_time, "ninetieth_response_time": s.get_response_time_percentile(0.9), "avg_content_length": s.avg_content_length, }) errors = [] for e in environment.runner.errors.values(): err_dict = e.to_dict() err_dict["name"] = escape(err_dict["name"]) err_dict["error"] = escape(err_dict["error"]) errors.append(err_dict) # Truncate the total number of stats and errors displayed since a large number of rows will cause the app # to render extremely slowly. Aggregate stats should be preserved. report = {"stats": stats[:500], "errors": errors[:500]} if len(stats) > 500: report["stats"] += [stats[-1]] if stats: report["total_rps"] = stats[len(stats) - 1]["current_rps"] report[ "fail_ratio"] = environment.runner.stats.total.fail_ratio report[ "current_response_time_percentile_95"] = environment.runner.stats.total.get_current_response_time_percentile( 0.95) report[ "current_response_time_percentile_50"] = environment.runner.stats.total.get_current_response_time_percentile( 0.5) is_distributed = isinstance(environment.runner, MasterLocustRunner) if is_distributed: workers = [] for worker in environment.runner.clients.values(): workers.append({ "id": worker.id, "state": worker.state, "user_count": worker.user_count, "cpu_usage": worker.cpu_usage }) report["workers"] = workers report["state"] = environment.runner.state report["user_count"] = environment.runner.user_count return jsonify(report) @app.route("/exceptions") @self.auth_required_if_enabled def exceptions(): return jsonify({ 'exceptions': [{ "count": row["count"], "msg": row["msg"], "traceback": row["traceback"], "nodes": ", ".join(row["nodes"]) } for row in environment.runner.exceptions.values()] }) @app.route("/exceptions/csv") @self.auth_required_if_enabled def exceptions_csv(): data = StringIO() writer = csv.writer(data) writer.writerow(["Count", "Message", "Traceback", "Nodes"]) for exc in environment.runner.exceptions.values(): nodes = ", ".join(exc["nodes"]) writer.writerow( [exc["count"], exc["msg"], exc["traceback"], nodes]) data.seek(0) response = make_response(data.read()) file_name = "exceptions_{0}.csv".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/csv" response.headers["Content-disposition"] = disposition return response def start(self, host, port): self.server = pywsgi.WSGIServer((host, port), self.app, log=None) self.server.serve_forever() def stop(self): self.server.stop() def auth_required_if_enabled(self, view_func): @wraps(view_func) def wrapper(*args, **kwargs): if self.app.config["BASIC_AUTH_ENABLED"]: if self.auth.authenticate(): return view_func(*args, **kwargs) else: return self.auth.challenge() else: return view_func(*args, **kwargs) return wrapper
class WebUI: """ Sets up and runs a Flask web app that can start and stop load tests using the :attr:`environment.runner <locust.env.Environment.runner>` as well as show the load test statistics in :attr:`environment.stats <locust.env.Environment.stats>` """ app: Optional[Flask] = None """ Reference to the :class:`flask.Flask` app. Can be used to add additional web routes and customize the Flask app in other various ways. Example:: from flask import request @web_ui.app.route("/my_custom_route") def my_custom_route(): return "your IP is: %s" % request.remote_addr """ greenlet: Optional[gevent.Greenlet] = None """ Greenlet of the running web server """ server: Optional[pywsgi.WSGIServer] = None """Reference to the :class:`pyqsgi.WSGIServer` instance""" template_args: Dict[str, Any] """Arguments used to render index.html for the web UI. Must be used with custom templates extending index.html.""" def __init__( self, environment: "Environment", host: str, port: int, auth_credentials: Optional[str] = None, tls_cert: Optional[str] = None, tls_key: Optional[str] = None, stats_csv_writer: Optional[StatsCSV] = None, delayed_start=False, ): """ Create WebUI instance and start running the web server in a separate greenlet (self.greenlet) Arguments: environment: Reference to the current Locust Environment host: Host/interface that the web server should accept connections to port: Port that the web server should listen to auth_credentials: If provided, it will enable basic auth with all the routes protected by default. Should be supplied in the format: "user:pass". tls_cert: A path to a TLS certificate tls_key: A path to a TLS private key delayed_start: Whether or not to delay starting web UI until `start()` is called. Delaying web UI start allows for adding Flask routes or Blueprints before accepting requests, avoiding errors. """ environment.web_ui = self self.stats_csv_writer = stats_csv_writer or StatsCSV( environment, stats_module.PERCENTILES_TO_REPORT) self.environment = environment self.host = host self.port = port self.tls_cert = tls_cert self.tls_key = tls_key app = Flask(__name__) CORS(app) self.app = app app.jinja_env.add_extension("jinja2.ext.do") app.debug = True app.root_path = os.path.dirname(os.path.abspath(__file__)) self.app.config["BASIC_AUTH_ENABLED"] = False self.auth: Optional[BasicAuth] = None self.greenlet: Optional[gevent.Greenlet] = None self._swarm_greenlet: Optional[gevent.Greenlet] = None self.template_args = {} if auth_credentials is not None: credentials = auth_credentials.split(":") if len(credentials) == 2: self.app.config["BASIC_AUTH_USERNAME"] = credentials[0] self.app.config["BASIC_AUTH_PASSWORD"] = credentials[1] self.app.config["BASIC_AUTH_ENABLED"] = True self.auth = BasicAuth() self.auth.init_app(self.app) else: raise AuthCredentialsError( "Invalid auth_credentials. It should be a string in the following format: 'user:pass'" ) if environment.runner: self.update_template_args() if not delayed_start: self.start() @app.route("/") @self.auth_required_if_enabled def index() -> Union[str, Response]: if not environment.runner: return make_response( "Error: Locust Environment does not have any runner", 500) self.update_template_args() return render_template("index.html", **self.template_args) @app.route("/swarm", methods=["POST"]) @self.auth_required_if_enabled def swarm() -> Response: assert request.method == "POST" parsed_options_dict = vars(environment.parsed_options ) if environment.parsed_options else {} for key, value in request.form.items(): if key == "user_count": # if we just renamed this field to "users" we wouldn't need this user_count = int(value) elif key == "spawn_rate": spawn_rate = float(value) elif key == "host": # Replace < > to guard against XSS environment.host = str(request.form["host"]).replace( "<", "").replace(">", "") elif key in parsed_options_dict: # update the value in environment.parsed_options, but dont change the type. # This won't work for parameters that are None parsed_options_dict[key] = type( parsed_options_dict[key])(value) if environment.shape_class and environment.runner is not None: environment.runner.start_shape() return jsonify({ "success": True, "message": "Swarming started using shape class", "host": environment.host }) if self._swarm_greenlet is not None: self._swarm_greenlet.kill(block=True) self._swarm_greenlet = None if environment.runner is not None: self._swarm_greenlet = gevent.spawn(environment.runner.start, user_count, spawn_rate) self._swarm_greenlet.link_exception(greenlet_exception_handler) return jsonify({ "success": True, "message": "Swarming started", "host": environment.host }) else: return jsonify({ "success": False, "message": "No runner", "host": environment.host }) @app.route("/stop") @self.auth_required_if_enabled def stop() -> Response: if self._swarm_greenlet is not None: self._swarm_greenlet.kill(block=True) self._swarm_greenlet = None if environment.runner is not None: environment.runner.stop() return jsonify({"success": True, "message": "Test stopped"}) @app.route("/stats/reset") @self.auth_required_if_enabled def reset_stats() -> str: environment.events.reset_stats.fire() if environment.runner is not None: environment.runner.stats.reset_all() environment.runner.exceptions = {} return "ok" @app.route("/stats/report") @self.auth_required_if_enabled def stats_report() -> Response: res = get_html_report( self.environment, show_download_link=not request.args.get("download")) if request.args.get("download"): res = app.make_response(res) res.headers[ "Content-Disposition"] = f"attachment;filename=report_{time()}.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. """ return f"{suggest_filename_prefix}_{time()}.csv" def _download_csv_response(csv_data: str, filename_prefix: str) -> Response: """Generate csv file download response with 'csv_data'. Arguments: csv_data: CSV header and data rows. filename_prefix: Prefix of the filename to suggest for saving the download. Will be appended with timestamp. """ response = make_response(csv_data) response.headers["Content-type"] = "text/csv" response.headers[ "Content-disposition"] = f"attachment;filename={_download_csv_suggest_file_name(filename_prefix)}" return response @app.route("/stats/requests/csv") @self.auth_required_if_enabled def request_stats_csv() -> Response: data = StringIO() writer = csv.writer(data) self.stats_csv_writer.requests_csv(writer) return _download_csv_response(data.getvalue(), "requests") @app.route("/stats/requests_full_history/csv") @self.auth_required_if_enabled def request_stats_full_history_csv() -> Response: options = self.environment.parsed_options if options and options.stats_history_enabled and isinstance( self.stats_csv_writer, StatsCSVFileWriter): return send_file( os.path.abspath( self.stats_csv_writer.stats_history_file_name()), mimetype="text/csv", as_attachment=True, download_name=_download_csv_suggest_file_name( "requests_full_history"), etag=True, cache_timeout=None, conditional=True, last_modified=None, ) return make_response( "Error: Server was not started with option to generate full history.", 404) @app.route("/stats/failures/csv") @self.auth_required_if_enabled def failures_stats_csv() -> Response: data = StringIO() writer = csv.writer(data) self.stats_csv_writer.failures_csv(writer) return _download_csv_response(data.getvalue(), "failures") @app.route("/stats/requests") @self.auth_required_if_enabled @memoize(timeout=DEFAULT_CACHE_TIME, dynamic_timeout=True) def request_stats() -> Response: stats: List[Dict[str, Any]] = [] errors: List[StatsErrorDict] = [] if environment.runner is None: report = { "stats": stats, "errors": errors, "total_rps": 0.0, "fail_ratio": 0.0, "current_response_time_percentile_95": None, "current_response_time_percentile_50": None, "state": STATE_MISSING, "user_count": 0, } if isinstance(environment.runner, MasterRunner): report.update({"workers": []}) return jsonify(report) for s in chain(sort_stats(environment.runner.stats.entries), [environment.runner.stats.total]): stats.append({ "method": s.method, "name": s.name, "safe_name": escape(s.name, quote=False), "num_requests": s.num_requests, "num_failures": s.num_failures, "avg_response_time": s.avg_response_time, "min_response_time": 0 if s.min_response_time is None else proper_round( s.min_response_time), "max_response_time": proper_round(s.max_response_time), "current_rps": s.current_rps, "current_fail_per_sec": s.current_fail_per_sec, "median_response_time": s.median_response_time, "ninetieth_response_time": s.get_response_time_percentile(0.9), "ninety_ninth_response_time": s.get_response_time_percentile(0.99), "avg_content_length": s.avg_content_length, }) for e in environment.runner.errors.values(): err_dict = e.serialize() err_dict["name"] = escape(err_dict["name"]) err_dict["error"] = escape(err_dict["error"]) errors.append(err_dict) # Truncate the total number of stats and errors displayed since a large number of rows will cause the app # to render extremely slowly. Aggregate stats should be preserved. truncated_stats = stats[:500] if len(stats) > 500: truncated_stats += [stats[-1]] report = {"stats": truncated_stats, "errors": errors[:500]} if stats: report["total_rps"] = stats[len(stats) - 1]["current_rps"] report[ "fail_ratio"] = environment.runner.stats.total.fail_ratio report[ "current_response_time_percentile_95"] = environment.runner.stats.total.get_current_response_time_percentile( 0.95) report[ "current_response_time_percentile_50"] = environment.runner.stats.total.get_current_response_time_percentile( 0.5) if isinstance(environment.runner, MasterRunner): workers = [] for worker in environment.runner.clients.values(): workers.append({ "id": worker.id, "state": worker.state, "user_count": worker.user_count, "cpu_usage": worker.cpu_usage, "memory_usage": worker.memory_usage, }) report["workers"] = workers report["state"] = environment.runner.state report["user_count"] = environment.runner.user_count return jsonify(report) @app.route("/exceptions") @self.auth_required_if_enabled def exceptions() -> Response: return jsonify({ "exceptions": [{ "count": row["count"], "msg": row["msg"], "traceback": row["traceback"], "nodes": ", ".join(row["nodes"]), } for row in (environment.runner.exceptions.values( ) if environment.runner is not None else [])] }) @app.route("/exceptions/csv") @self.auth_required_if_enabled def exceptions_csv() -> Response: data = StringIO() writer = csv.writer(data) self.stats_csv_writer.exceptions_csv(writer) return _download_csv_response(data.getvalue(), "exceptions") @app.route("/tasks") @self.auth_required_if_enabled def tasks() -> Dict[str, Dict[str, Dict[str, float]]]: runner = self.environment.runner user_spawned: Dict[str, int] if runner is None: user_spawned = {} else: user_spawned = (runner.reported_user_classes_count if isinstance(runner, MasterRunner) else runner.user_classes_count) task_data = { "per_class": get_ratio(self.environment.user_classes, user_spawned, False), "total": get_ratio(self.environment.user_classes, user_spawned, True), } return task_data def start(self): self.greenlet = gevent.spawn(self.start_server) self.greenlet.link_exception(greenlet_exception_handler) def start_server(self): if self.tls_cert and self.tls_key: self.server = pywsgi.WSGIServer((self.host, self.port), self.app, log=None, keyfile=self.tls_key, certfile=self.tls_cert) else: self.server = pywsgi.WSGIServer((self.host, self.port), self.app, log=None) self.server.serve_forever() def stop(self): """ Stop the running web server """ self.server.stop() def auth_required_if_enabled(self, view_func): """ Decorator that can be used on custom route methods that will turn on Basic Auth authentication if the ``--web-auth`` flag is used. Example:: @web_ui.app.route("/my_custom_route") @web_ui.auth_required_if_enabled def my_custom_route(): return "custom response" """ @wraps(view_func) def wrapper(*args, **kwargs): if self.app.config["BASIC_AUTH_ENABLED"]: if self.auth.authenticate(): return view_func(*args, **kwargs) else: return self.auth.challenge() else: return view_func(*args, **kwargs) return wrapper def update_template_args(self): override_host_warning = False if self.environment.host: host = self.environment.host elif self.environment.runner.user_classes: all_hosts = {l.host for l in self.environment.runner.user_classes} if len(all_hosts) == 1: host = list(all_hosts)[0] else: # since we have multiple User classes with different host attributes, we'll # inform that specifying host will override the host for all User classes override_host_warning = True host = None else: host = None options = self.environment.parsed_options is_distributed = isinstance(self.environment.runner, MasterRunner) if is_distributed: worker_count = self.environment.runner.worker_count else: worker_count = 0 stats = self.environment.runner.stats extra_options = argument_parser.ui_extra_args_dict() self.template_args = { "locustfile": self.environment.locustfile, "state": self.environment.runner.state, "is_distributed": is_distributed, "user_count": self.environment.runner.user_count, "version": version, "host": host, "history": stats.history if stats.num_requests > 0 else {}, "override_host_warning": override_host_warning, "num_users": options and options.num_users, "spawn_rate": options and options.spawn_rate, "worker_count": worker_count, "is_shape": self.environment.shape_class, "stats_history_enabled": options and options.stats_history_enabled, "tasks": dumps({}), "extra_options": extra_options, }
class WebUI: """ Sets up and runs a Flask web app that can start and stop load tests using the :attr:`environment.runner <locust.env.Environment.runner>` as well as show the load test statistics in :attr:`environment.stats <locust.env.Environment.stats>` """ app = None greenlet = None server = None etcdt = None reporter_running_status=False """Reference to the :class:`pyqsgi.WSGIServer` instance""" def __init__(self,environment,host,port,masterHost,auth_credentials=None,tls_cert=None,tls_key=None,stats_csv_writer=None): """ Create WebUI instance and start running the web server in a separate greenlet (self.greenlet) Arguments: environment: Reference to the curren Locust Environment host: Host/interface that the web server should accept connections to port: Port that the web server should listen to auth_credentials: If provided, it will enable basic auth with all the routes protected by default. Should be supplied in the format: "user:pass". tls_cert: A path to a TLS certificate tls_key: A path to a TLS private key """ environment.web_ui = self self.stats_csv_writer = stats_csv_writer or StatsCSV(environment,stats_module.PERCENTILES_TO_REPORT) self.environment = environment self.host = host self.port = port self.tls_cert = tls_cert self.tls_key = tls_key app = Flask(__name__) self.app = app app.debug = True app.root_path = os.path.dirname(os.path.abspath(__file__)) self.app.config["BASIC_AUTH_ENABLED"] = False self.auth = None self.greenlet = None self.masterHost = masterHost self.etcdt=EtcdTooler("/ns/boomer_service/") self.workedServser={} self.recvMesg={} if auth_credentials is not None: credentials = auth_credentials.split(':') if len(credentials) == 2: self.app.config["BASIC_AUTH_USERNAME"] = credentials[0] self.app.config["BASIC_AUTH_PASSWORD"] = credentials[1] self.app.config["BASIC_AUTH_ENABLED"] = True self.auth = BasicAuth() self.auth.init_app(self.app) else: raise AuthCredentialsError( "Invalid auth_credentials. It should be a string in the following format: 'user.pass'") def stats_history(self): """Save current stats info to history for charts of report.""" while self.reporter_running_status: stats = environment.runner.stats if not stats.total.use_response_times_cache: break r = { "time": datetime.datetime.now().strftime("%H:%M:%S"), "current_rps": stats.total.current_rps or 0, "current_fail_per_sec": stats.total.current_fail_per_sec or 0, "response_time_percentile_95": stats.total.get_current_response_time_percentile(0.95) or 0, "response_time_percentile_50": stats.total.get_current_response_time_percentile(0.5) or 0, "user_count": environment.runner.user_count or 0, } stats.history.append(r) gevent.sleep(5) logger.info("......结束了指标历史记录任务......") def initTask(self,rpcServAddr,initBommerRequest): """ 调用gRPC,通知初始化Boomer,改成同步处理 :param rpcServAddr: :return: """ s=datetime.datetime.now() try: channel = grpc.insecure_channel(rpcServAddr) # 连接 rpc 服务器 # 调用 rpc 服务 stub = boomerCall_pb2_grpc.BoomerCallServiceStub(channel) except Exception as e: logger.error(e) return beforeClientIds=[ worker.id for worker in environment.runner.clients.values()] try: print(initBommerRequest) response = stub.InitBommer(initBommerRequest,timeout=15) self.recvMesg[rpcServAddr] = response.message except Exception as e: self.recvMesg[rpcServAddr] = "连接服务器异常:%s"%(e) return if response and response.status: tryCount=0 newId="" while tryCount<=50: # 5s的超时 afterClientIds = [worker.id for worker in environment.runner.clients.values()] dfIdSet = set(afterClientIds).difference(set(beforeClientIds)) if not dfIdSet: gevent.sleep(0.1) tryCount+=1 else: newId=dfIdSet.pop() break self.workedServser[rpcServAddr]=newId try: channel.close() except: pass return def shutTask(self,rpcServAddr): """ 调用gRPC,通知关闭Boomer :param rpcServAddr: :return: """ try: channel = grpc.insecure_channel(rpcServAddr) # 连接 rpc 服务器 # 调用 rpc 服务 stub = boomerCall_pb2_grpc.BoomerCallServiceStub(channel) response = stub.EndBommer(boomerCall_pb2.EndBommerRequest(),timeout=15) except Exception as e: self.recvMesg[rpcServAddr]="连接服务器异常:%s"%(e) return self.recvMesg[rpcServAddr] = response.message if response and response.status: if self.workedServser.__contains__(rpcServAddr): del self.workedServser[rpcServAddr] if self.recvMesg.__contains__(rpcServAddr): # 成功情况下,不需要获取消息 del self.recvMesg[rpcServAddr] try: channel.close() except: pass @app.route('/') @self.auth_required_if_enabled def index(): if not environment.runner: return make_response("Error: Locust Environment does not have any runner",500) is_distributed = isinstance(environment.runner,MasterRunner) if is_distributed: worker_count = environment.runner.worker_count slave_count = len(self.etcdt.servAddressList) else: worker_count = 0 slave_count = 0 override_host_warning = False if environment.host: host = environment.host elif environment.runner.user_classes: all_hosts = set([l.host for l in environment.runner.user_classes]) if len(all_hosts) == 1: host = list(all_hosts)[0] else: # since we have mulitple User classes with different host attributes, we'll # inform that specifying host will override the host for all User classes override_host_warning = True host = None else: host = None return render_template("index.html", state=environment.runner.state, is_distributed=is_distributed, user_count=environment.runner.user_count, version=version, host=host, override_host_warning=override_host_warning, worker_count=worker_count, slave_count= slave_count, is_step_load=environment.step_load, ) @app.route('/swarm',methods=["POST"]) @self.auth_required_if_enabled def swarm(): if environment.runner.state not in (runners.STATE_STOPPED,runners.STATE_INIT): return jsonify({'success': False,'message': '当前有任务正在执行,先停止测试再尝试'}) if not environment.runner.worker_count: return jsonify({'success': False,'message': '没有可用的work, 不能运行测试'}) assert request.method == "POST" # 清理下统计信息 environment.runner.stats.clear_all() environment.runner.exceptions = {} # 开启协程写入指标历史记录 self.reporter_running_status=True gevent.spawn(stats_history,self) # 开始压测任务 user_count = int(request.form["user_count"]) hatch_rate = float(request.form["hatch_rate"]) run_seconds=None if request.form["run_time"]: try: run_seconds = parse_timespan(request.form["run_time"]) except ValueError: pass if request.form.get("host"): environment.host = str(request.form["host"]) def stopRunAfterSecs(x): if x>3600*6: x=3600*6 count=0 while count<=x: # 这种方式避免长时间休眠造成问题 gevent.sleep(1) count+=1 self.reporter_running_status = False # 结束指标历史记录 environment.runner.stop() if run_seconds and run_seconds>=30: gevent.spawn(stopRunAfterSecs,run_seconds) step_user_count=None step_duration=None try: step_user_count = int(request.form["step_user_count"]) step_duration = parse_timespan(str(request.form["step_duration"])) except: pass if environment.step_load and step_user_count and step_duration: environment.runner.start_stepload(user_count,hatch_rate,step_user_count,step_duration) return jsonify( {'success': True,'message': 'Swarming started in Step Load Mode','host': environment.host}) environment.runner.start(user_count,hatch_rate) return jsonify({'success': True,'message': 'Swarming started','host': environment.host}) @app.route('/stop') @self.auth_required_if_enabled def stop(): self.reporter_running_status=False #结束指标历史记录 if environment.runner.state not in (runners.STATE_SPAWNING,runners.STATE_STOPPING,runners.STATE_STOPPED,runners.STATE_INIT): environment.runner.stop() return jsonify({'success': True,'message': '测试已经停止'}) elif environment.runner.state == runners.STATE_SPAWNING: environment.runner.stop() return jsonify({'success': False,'message': '测试用户增加中,请多试几次'}) else: return jsonify({'success': False,'message': '测试没有运行'}) @app.route("/stats/reset") @self.auth_required_if_enabled def reset_stats(): if environment.runner.state == runners.STATE_STOPPED: environment.runner.stats.clear_all() environment.runner.exceptions = {} environment.host=None return "ok" environment.runner.stats.reset_all() environment.runner.exceptions = {} return "ok" def makeHttpItem(request,prefixStr,i): """ 构造任务 :return: """ xPretask = {} pretask_x_method = request.form.get("%s-%d-method" % (prefixStr,i),type=str,default="Get") xPretask["Method"] = pretask_x_method pretask_x_urlPath = request.form.get("%s-%d-urlPath" % (prefixStr,i),type=str,default="") xPretask["UrlPath"] = pretask_x_urlPath # Headers 处理 pretask_x_headers_key = request.form.getlist("%s-%d-headers-key" % (prefixStr,i)) pretask_x_headers_value = request.form.getlist("%s-%d-headers-value" % (prefixStr,i)) if pretask_x_headers_key and pretask_x_headers_value and \ len(pretask_x_headers_key) == len(pretask_x_headers_value): xPretask["Headers"]={} for j in range(len(pretask_x_headers_key)): xPretask["Headers"][pretask_x_headers_key[j]] = pretask_x_headers_value[j] else: xPretask["Headers"] = None # Params 处理 pretask_x_params_key = request.form.getlist("%s-%d-params-key" % (prefixStr,i)) pretask_x_params_value = request.form.getlist("%s-%d-params-value" % (prefixStr,i)) if pretask_x_params_key and pretask_x_params_value and \ len(pretask_x_params_key) == len(pretask_x_params_value): xPretask["Params"]={} for j in range(len(pretask_x_params_key)): xPretask["Params"][pretask_x_params_key[j]] = pretask_x_params_value[j] else: xPretask["Params"] = None # DictData 处理 pretask_x_dictdata_key = request.form.getlist("%s-%d-dictdata-key" % (prefixStr,i)) pretask_x_dictdata_value = request.form.getlist("%s-%d-dictdata-value" % (prefixStr,i)) if pretask_x_dictdata_key and pretask_x_dictdata_value and \ len(pretask_x_dictdata_key) == len(pretask_x_dictdata_value): xPretask["DictData"]={} for j in range(len(pretask_x_dictdata_key)): xPretask["DictData"][pretask_x_dictdata_key[j]] = pretask_x_dictdata_value[j] else: xPretask["DictData"] = None # RawData 处理 pretask_x_rawdata = request.form.get("%s-%d-rawdata" % (prefixStr,i),type=str,default="") xPretask["RawData"] = pretask_x_rawdata # JsonData 处理 pretask_x_jsondata = request.form.get("%s-%d-jsondata" % (prefixStr,i),type=str,default="") xPretask["JsonData"] = pretask_x_jsondata # AssertChain 处理 pretask_x_assertType = request.form.getlist("%s-%d-assertType" % (prefixStr,i)) pretask_x_assertValue = request.form.getlist("%s-%d-assertValue" % (prefixStr,i)) if pretask_x_assertType and pretask_x_assertValue and \ len(pretask_x_assertType) == len(pretask_x_assertValue): xPretask["AssertChain"] = [] for j in range(len(pretask_x_assertType)): xPretask["AssertChain"].append({ "AssertType": parse2Int4saveTrans(pretask_x_assertType[j]), "RuleValue": parse2Int4saveTrans(pretask_x_assertValue[j]) }) else: xPretask["AssertChain"] = None # SaveParamAction 处理 pretask_x_saveType = request.form.getlist("%s-%d-saveType" % (prefixStr,i)) pretask_x_paramName = request.form.getlist("%s-%d-paramName" % (prefixStr,i)) pretask_x_ruleValue = request.form.getlist("%s-%d-ruleValue" % (prefixStr,i)) if pretask_x_saveType and pretask_x_paramName and pretask_x_ruleValue and \ len(pretask_x_saveType) == len(pretask_x_paramName) and \ len(pretask_x_saveType) == len(pretask_x_ruleValue): xPretask["SaveParamChain"] = [] for j in range(len(pretask_x_saveType)): xPretask["SaveParamChain"].append({ "SaveType": parse2Int4saveTrans(pretask_x_saveType[j]), "ParamName": pretask_x_paramName[j], "RuleValue": pretask_x_ruleValue[j] }) else: xPretask["SaveParamChain"] = None return xPretask @app.route('/resetTrans',methods=["POST"]) @self.auth_required_if_enabled def resetTransation(): with open("./jsons/main.json",mode="w") as f: f.write("{}") return jsonify({'success': True,'message': ''}) @app.route('/saveTrans',methods=["POST"]) @self.auth_required_if_enabled def saveTransation(): if environment.runner.state not in (runners.STATE_STOPPED,runners.STATE_INIT): return jsonify({'success': False,'message': '当前有任务正在执行,先停止测试再尝试'}) transObj={} isSession=request.form.get("isSession",type=int,default=0) transObj["isSession"]=isSession==1 httpProxy=request.form.get("HttpProxy",type=str,default="") transObj["HttpProxy"] = httpProxy preTaskMark=request.form.get("PreTaskMark",type=int,default=0) # PreTask 处理 if preTaskMark==0: transObj["PreTask"] = None else: preTask=[] for i in range(1,preTaskMark+1): preTask.append(makeHttpItem(request,"pretask",i)) transObj["PreTask"]=preTask # TestTask 处理 testTask=[] TestTaskIdMark = request.form.get("TestTaskIdMark",type=str,default="1") TestTaskMark=request.form.get("TestTaskMark",type=str,default="0") if(len(TestTaskIdMark.split(","))!=len(TestTaskMark.split(",")) or len(TestTaskIdMark.split(","))<1): return jsonify({'success': False,'message': '测试事务的id号出现错乱,可能需要重新来!'}) for i,ttm in enumerate(TestTaskMark.split(",")): taskId=parse2Int4saveTrans(TestTaskIdMark.split(",")[i]) if taskId==0: return jsonify({'success': False,'message': '测试事务的id号出现错误,可能需要重新来!'}) xTestTask={} xtm=parse2Int4saveTrans(ttm) xTaskWeight=request.form.get("testtask-%d-taskWeight"%(taskId),type=int,default=0) xTestTask["TaskWeight"]=xTaskWeight xTaskName=request.form.get("testtask-%d-TaskName"%(taskId),type=str,default="测试事务%d"%(i+1)) xTestTask["TaskName"] = xTaskName if xtm==0: xTestTask["PreWork"]=None else: preWork=[] for j in range(1,xtm + 1): preWork.append(makeHttpItem(request,"testtask-%d-prework"%(taskId),j)) xTestTask["PreWork"]=preWork xTestTask["TestWork"]=makeHttpItem(request,"testwork",taskId) testTask.append(xTestTask) transObj["MainTask"]=testTask try: with open("./jsons/main.json",mode="w+") as f: f.write(json.dumps(transObj,indent=4)) return jsonify({'success': True,'message': '保存成功'}) except Exception as e: return jsonify({'success': False,'message': e}) @app.route('/importTrans',methods=['Post']) @self.auth_required_if_enabled def importTrans(): try: f=request.files["file"] f.save("./jsons/tmp.json") return jsonify({'success': True,'message': ''}) except Exception as e: return jsonify({'success': False,'message': str(e)+"\n"+traceback.format_exc()}) @app.route('/transaction',methods=["GET"]) @self.auth_required_if_enabled def transation(): return render_template("transaction.html") @app.route('/importedTrans',methods=['Get']) @self.auth_required_if_enabled def importedTrans(): if request.args.get("from_save",type=str,default="0")=="1": transPath="./jsons/main.json" else: transPath="./jsons/tmp.json" try: with open(transPath,mode='rb') as f: transation=json.load(f) if not transation.get("PreTask"): PreTaskMark=0 else: PreTaskMark=len(transation.get("PreTask")) if not transation.get("MainTask"): logger.error("json中没有MainTask") return render_template("transaction.html") TestTaskMark=[] TestTaskIdMark=[] tid=0 for mt in transation.get("MainTask"): TestTaskIdMark.append(tid+1) tid+=1 if not mt.get("PreWork"): TestTaskMark.append(0) else: TestTaskMark.append(len(mt.get("PreWork"))) transMark = { "PreTaskMark": PreTaskMark, "TestTaskMark": TestTaskMark, "TestTaskIdMark": TestTaskIdMark, "TestTaskId":tid } return render_template("importedTransaction.html",transMark=transMark,transation=transation) except Exception as e: logger.error(e) return render_template("transaction.html") @app.route('/backupTrans',methods=['POST']) @self.auth_required_if_enabled def backupTrans(): transObj = {} isSession = request.form.get("isSession",type=int,default=0) transObj["isSession"] = isSession == 1 httpProxy = request.form.get("HttpProxy",type=str,default="") transObj["HttpProxy"] = httpProxy preTaskMark = request.form.get("PreTaskMark",type=int,default=0) # PreTask 处理 if preTaskMark == 0: transObj["PreTask"] = None else: preTask = [] for i in range(1,preTaskMark + 1): preTask.append(makeHttpItem(request,"pretask",i)) transObj["PreTask"] = preTask # TestTask 处理 testTask = [] TestTaskIdMark = request.form.get("TestTaskIdMark",type=str,default="1") TestTaskMark = request.form.get("TestTaskMark",type=str,default="0") if (len(TestTaskIdMark.split(",")) != len(TestTaskMark.split(",")) or len(TestTaskIdMark.split(",")) < 1): return render_template("transaction.html") for i,ttm in enumerate(TestTaskMark.split(",")): taskId = parse2Int4saveTrans(TestTaskIdMark.split(",")[i]) if taskId == 0: return render_template("transaction.html") xTestTask = {} xtm = parse2Int4saveTrans(ttm) xTaskWeight = request.form.get("testtask-%d-taskWeight" % (taskId),type=int,default=0) xTestTask["TaskWeight"] = xTaskWeight xTaskName = request.form.get("testtask-%d-TaskName" % (taskId),type=str, default="测试事务%d" % (i + 1)) xTestTask["TaskName"] = xTaskName if xtm == 0: xTestTask["PreWork"] = None else: preWork = [] for j in range(1,xtm + 1): preWork.append(makeHttpItem(request,"testtask-%d-prework" % (taskId),j)) xTestTask["PreWork"] = preWork xTestTask["TestWork"] = makeHttpItem(request,"testwork",taskId) testTask.append(xTestTask) transObj["MainTask"] = testTask rawData = StringIO() rawData.write(json.dumps(transObj,indent=4)) rawData.seek(0) response = make_response(rawData.getvalue()) rawData.close() file_name = "backup_{0}.json".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/json" response.headers["Content-disposition"] = disposition return response @app.route('/download_boomer',methods=['GET']) @self.auth_required_if_enabled def downloadBoomer(): if os.path.isfile(os.path.join("./slaveEXE","boomerHazardServer.exe")): return send_from_directory("./slaveEXE","boomerHazardServer.exe",as_attachment=True) @app.route('/download_boomer_linux',methods=['GET']) @self.auth_required_if_enabled def downloadBoomerLinux(): if os.path.isfile(os.path.join("./slaveEXE","boomerHazardServer")): return send_from_directory("./slaveEXE","boomerHazardServer",as_attachment=True) @app.route('/initBoomer',methods=["POST"]) @self.auth_required_if_enabled def initBoomer(): if not self.etcdt.servAddressList: return jsonify({'success': False,'message': '没有可用的压力机'}) selectServAddrList = request.form.getlist("servAddr[]") if not selectServAddrList: return jsonify({'success': False,'message': '请选择压力机'}) initBommerRequest,errMsg = makeInitBoomerRequest("jsons/main.json",self.masterHost) if not initBommerRequest: return jsonify({'success': False,'message': errMsg}) for rpcServAddr in set(self.etcdt.servAddressList).intersection(set(selectServAddrList)): initTask(self,rpcServAddr,initBommerRequest) return jsonify({'success': True,'message': '已通知压测机初始化,请检查Workers中各压力机的最新消息'}) @app.route('/shutdownBoomer',methods=["POST"]) @self.auth_required_if_enabled def shutdownBoomer(): if environment.runner.state not in (runners.STATE_STOPPED,runners.STATE_INIT): return jsonify({'success': False,'message': '当前有任务正在执行,先停止测试再尝试'}) if not self.etcdt.servAddressList: return jsonify({'success': False,'message': '没有可用的压力机'}) for rpcServAddr in self.etcdt.servAddressList: # 全部关闭 shutTask(self,rpcServAddr) return jsonify({'success': True,'message': '已通知压测机停止,请检查Workers中各压力机的最新情况'}) def _download_csv_suggest_file_name(suggest_filename_prefix): """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. """ return f"{suggest_filename_prefix}_{time()}.csv" def _download_csv_response(csv_data, filename_prefix): """Generate csv file download response with 'csv_data'. Arguments: csv_data: CSV header and data rows. filename_prefix: Prefix of the filename to suggest for saving the download. Will be appended with timestamp. """ response = make_response(csv_data) response.headers["Content-type"] = "text/csv" response.headers[ "Content-disposition" ] = f"attachment;filename={_download_csv_suggest_file_name(filename_prefix)}" return response @app.route("/stats/requests/csv") @self.auth_required_if_enabled def request_stats_csv(): data = StringIO() writer = csv.writer(data) self.stats_csv_writer.requests_csv(writer) return _download_csv_response(data.getvalue(),"requests") @app.route("/stats/requests_full_history/csv") @self.auth_required_if_enabled def request_stats_full_history_csv(): options = self.environment.parsed_options if options and options.stats_history_enabled: return send_file( os.path.abspath(self.stats_csv_writer.stats_history_file_name()), mimetype="text/csv", as_attachment=True, attachment_filename=_download_csv_suggest_file_name("requests_full_history"), add_etags=True, cache_timeout=None, conditional=True, last_modified=None, ) return make_response("Error: Server was not started with option to generate full history.",404) @app.route("/stats/failures/csv") @self.auth_required_if_enabled def failures_stats_csv(): data = StringIO() writer = csv.writer(data) self.stats_csv_writer.failures_csv(writer) return _download_csv_response(data.getvalue(),"failures") @app.route('/stats/requests') @self.auth_required_if_enabled @memoize(timeout=DEFAULT_CACHE_TIME,dynamic_timeout=True) def request_stats(): stats = [] for s in chain(sort_stats(self.environment.runner.stats.entries),[environment.runner.stats.total]): stats.append( { "method": s.method, "name": s.name, "safe_name": escape(s.name,quote=False), "num_requests": s.num_requests, "num_failures": s.num_failures, "avg_response_time": s.avg_response_time, "min_response_time": 0 if s.min_response_time is None else proper_round(s.min_response_time), "max_response_time": proper_round(s.max_response_time), "current_rps": s.current_rps, "current_fail_per_sec": s.current_fail_per_sec, "median_response_time": s.median_response_time, "ninetieth_response_time": s.get_response_time_percentile(0.9), "avg_content_length": s.avg_content_length, } ) errors = [] for e in environment.runner.errors.values(): err_dict = e.to_dict() err_dict["name"] = escape(err_dict["name"]) err_dict["error"] = escape(err_dict["error"]) errors.append(err_dict) # Truncate the total number of stats and errors displayed since a large number of rows will cause the app # to render extremely slowly. Aggregate stats should be preserved. report = {"stats": stats[:500],"errors": errors[:500]} if len(stats) > 500: report["stats"] += [stats[-1]] if stats: report["total_rps"] = stats[len(stats) - 1]["current_rps"] report["fail_ratio"] = environment.runner.stats.total.fail_ratio report[ "current_response_time_percentile_95"] = environment.runner.stats.total.get_current_response_time_percentile( 0.95) report[ "current_response_time_percentile_50"] = environment.runner.stats.total.get_current_response_time_percentile( 0.5) is_distributed = isinstance(environment.runner,MasterRunner) if is_distributed: workers = [] missingClientIds=[] for key,worker in environment.runner.clients.items(): if worker.state==runners.STATE_MISSING: missingClientIds.append(key) continue workers.append({ "id": worker.id, "state": worker.state, "user_count": worker.user_count, "cpu_usage": worker.cpu_usage }) # 移除missing的worker for missingClientId in missingClientIds: del environment.runner.clients[missingClientId] report["workers"] = workers report["slaves"] = [{ "slave":x, "clientId": (lambda t:"-" if not t or not t in [w.get("id") for w in workers] else t)(self.workedServser.get(x)), "rectMsg": (lambda t: "" if not t else t)(self.recvMesg.get(x)) } for x in self.etcdt.servAddressList ] # print("environment.runner.state",environment.runner.state) report["state"] = environment.runner.state report["user_count"] = environment.runner.user_count return jsonify(report) @app.route("/exceptions") @self.auth_required_if_enabled def exceptions(): return jsonify({ 'exceptions': [ { "count": row["count"], "msg": row["msg"], "traceback": row["traceback"], "nodes": ", ".join(row["nodes"]) } for row in environment.runner.exceptions.values() ] }) @app.route("/exceptions/csv") @self.auth_required_if_enabled def exceptions_csv(): data = StringIO() writer = csv.writer(data) writer.writerow(["Count","Message","Traceback","Nodes"]) for exc in environment.runner.exceptions.values(): nodes = ", ".join(exc["nodes"]) writer.writerow([exc["count"],exc["msg"],exc["traceback"],nodes]) response = make_response(data.getvalue()) file_name = "exceptions_{0}.csv".format(time()) disposition = "attachment;filename={0}".format(file_name) response.headers["Content-type"] = "text/csv" response.headers["Content-disposition"] = disposition return response # start the web server self.greenlet = gevent.spawn(self.start) self.greenlet.link_exception(greenlet_exception_handler) @app.route("/stats/report") @self.auth_required_if_enabled def stats_report(): stats = self.environment.runner.stats if not stats or not stats.start_time or not stats.last_request_timestamp or not stats.entries: return render_template( "report.html") start_ts = stats.start_time start_time = datetime.datetime.fromtimestamp(start_ts) start_time = start_time.strftime("%Y-%m-%d %H:%M:%S") end_ts = stats.last_request_timestamp end_time = datetime.datetime.fromtimestamp(end_ts) end_time = end_time.strftime("%Y-%m-%d %H:%M:%S") host = None if environment.host: host = environment.host elif environment.runner.user_classes: all_hosts = set([l.host for l in environment.runner.user_classes]) if len(all_hosts) == 1: host = list(all_hosts)[0] requests_statistics = list(chain(sort_stats(stats.entries),[stats.total])) failures_statistics = sort_stats(stats.errors) exceptions_statistics = [] for exc in environment.runner.exceptions.values(): exc["nodes"] = ", ".join(exc["nodes"]) exceptions_statistics.append(exc) history = stats.history static_js = "" js_files = ["jquery-1.11.3.min.js","echarts.common.min.js","vintage.js","chart.js"] for js_file in js_files: path = os.path.join(os.path.dirname(__file__),"static",js_file) with open(path,encoding="utf8") as f: content = f.read() static_js += "// " + js_file + "\n" static_js += content static_js += "\n\n\n" res = render_template( "report.html", int=int, round=round, requests_statistics=requests_statistics, failures_statistics=failures_statistics, exceptions_statistics=exceptions_statistics, start_time=start_time, end_time=end_time, host=host, history=history, static_js=static_js, ) if request.args.get("download"): res = app.make_response(res) res.headers["Content-Disposition"] = "attachment;filename=report_%s.html" % time() return res def start(self): if self.tls_cert and self.tls_key: self.server = pywsgi.WSGIServer((self.host,self.port),self.app,log=None,keyfile=self.tls_key, certfile=self.tls_cert) else: self.server = pywsgi.WSGIServer((self.host,self.port),self.app,log=None) self.server.serve_forever() def stop(self): """ Stop the running web server """ self.server.stop() def auth_required_if_enabled(self,view_func): """ Decorator that can be used on custom route methods that will turn on Basic Auth authentication if the ``--web-auth`` flag is used. Example:: @web_ui.app.route("/my_custom_route") @web_ui.auth_required_if_enabled def my_custom_route(): return "custom response" """ @wraps(view_func) def wrapper(*args,**kwargs): if self.app.config["BASIC_AUTH_ENABLED"]: if self.auth.authenticate(): return view_func(*args,**kwargs) else: return self.auth.challenge() else: return view_func(*args,**kwargs) return wrapper
def is_accessible(self): basic_auth = BasicAuth(app) if not basic_auth.authenticate(): raise AuthException('Not authenticated.') else: return True