diff --git a/src/common/db/Database.py b/src/common/db/Database.py index b336d2652..9726246b3 100644 --- a/src/common/db/Database.py +++ b/src/common/db/Database.py @@ -3097,6 +3097,33 @@ def add_instance(self, hostname: str, port: int, server_name: str, method: str, return "" + def delete_instance(self, hostname: str, changed: Optional[bool] = True) -> str: + """Delete instance.""" + with self._db_session() as session: + if self.readonly: + return "The database is read-only, the changes will not be saved" + + db_instance = session.query(Instances).filter_by(hostname=hostname).first() + + if db_instance is None: + return f"Instance {hostname} does not exist, will not be deleted." + + session.delete(db_instance) + + if changed: + with suppress(ProgrammingError, OperationalError): + metadata = session.query(Metadata).get(1) + if metadata is not None: + metadata.instances_changed = True + metadata.last_instances_change = datetime.now() + + try: + session.commit() + except BaseException as e: + return f"An error occurred while deleting the instance {hostname}.\n{e}" + + return "" + def update_instances(self, instances: List[Dict[str, Any]], method: str, changed: Optional[bool] = True) -> str: """Update instances.""" to_put = [] diff --git a/src/ui/main.py b/src/ui/main.py index ebc8eaeac..92ab7e91b 100644 --- a/src/ui/main.py +++ b/src/ui/main.py @@ -8,7 +8,7 @@ from string import ascii_letters, digits from sys import path as sys_path, modules as sys_modules from pathlib import Path -from typing import Any, Dict, Optional, Tuple, Union +from typing import Any, Dict, Literal, Optional, Tuple, Union from uuid import uuid4 @@ -47,21 +47,20 @@ from src.totp import Totp from src.ui_data import UIData -# TODO: rename to bans -from builder.bans2 import bans_builder # type: ignore - -# TODO: rename to reports -from builder.reports2 import reports_builder # type: ignore +from builder.advanced_mode import advanced_mode_builder # type: ignore +from builder.bans import bans_builder # type: ignore +# from builder.configs import configs_builder # type: ignore +from builder.easy_mode import easy_mode_builder # type: ignore +from builder.global_config import global_config_builder # type: ignore from builder.home import home_builder # type: ignore from builder.instances import instances_builder # type: ignore -from builder.global_config import global_config_builder # type: ignore from builder.jobs import jobs_builder # type: ignore -from builder.services import services_builder # type: ignore -from builder.raw_mode import raw_mode_builder # type: ignore -from builder.advanced_mode import advanced_mode_builder # type: ignore -from builder.easy_mode import easy_mode_builder # type: ignore from builder.logs import logs_builder # type: ignore +from builder.raw_mode import raw_mode_builder # type: ignore +from builder.reports import reports_builder # type: ignore +from builder.services import services_builder # type: ignore + from common_utils import get_version # type: ignore from logger import setup_logger # type: ignore @@ -245,6 +244,12 @@ def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: b operation = instance.restart() else: operation = "The instance does not exist." + elif operation == "ping": + instance = Instance.from_hostname(args[0], app.db) + if instance: + operation = instance.ping()[0] + else: + operation = "The instance does not exist." elif not error: operation = "The scheduler will be in charge of applying the changes." @@ -364,6 +369,9 @@ def run_action(plugin: str, function_name: str = "", *, tmp_dir: Optional[Path] def verify_data_in_form( data: Optional[Dict[str, Union[Tuple, Any]]] = None, err_message: str = "", redirect_url: str = "", next: bool = False ) -> Union[bool, Response]: + app.logger.debug(f"Verifying data in form: {data}") + app.logger.debug(f"Request form: {request.form}") + # Loop on each key in data for key, values in (data or {}).items(): if key not in request.form: @@ -987,49 +995,135 @@ def update_global_config(threaded: bool = False): ) -@app.route("/instances", methods=["GET", "POST"]) +### * INSTANCES + + +@app.route("/instances", methods=["GET"]) @login_required def instances(): - # Manage instances - if request.method == "POST": - - verify_data_in_form(data={"INSTANCE_ID": None}, err_message="Missing instance id parameter on /instances.", redirect_url="instances", next=True) - verify_data_in_form( - data={ - "operation": ( - "reload", - "start", - "stop", - "restart", - ) - }, - err_message="Missing operation parameter on /instances.", - redirect_url="instances", - next=True, + instances = [] + instances_types = set() + instances_methods = set() + instances_healths = set() + + for instance in app.bw_instances_utils.get_instances(): + instances.append( + { + "hostname": instance.hostname, + "name": instance.name, + "method": instance.method, + "health": instance.status, + "type": instance.type, + "creation_date": instance.creation_date.strftime("%Y-%m-%d %H:%M:%S"), + "last_seen": instance.last_seen.strftime("%Y-%m-%d %H:%M:%S"), + } ) - app.data["RELOADING"] = True - app.data["LAST_RELOAD"] = time() - Thread( - target=manage_bunkerweb, - name="Reloading instances", - args=("instances", request.form["INSTANCE_ID"]), - kwargs={"operation": request.form["operation"], "threaded": True}, - ).start() + instances_types.add(instance.type) + instances_methods.add(instance.method) + instances_healths.add(instance.status) - return redirect( - url_for( - "loading", - next=url_for("instances"), - message=(f"{request.form['operation'].title()}ing" if request.form["operation"] != "stop" else "Stopping") + " instance", - ) - ) + builder = instances_builder(instances, list(instances_types), list(instances_methods), list(instances_healths)) + return render_template("instances.html", title="Instances", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii")) - # Display instances - instances = app.bw_instances_utils.get_instances() - builder = instances_builder(instances) - return render_template("instances.html", title="Instances", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii")) +@app.route("/instances/new", methods=["PUT"]) +@login_required +def instances_new(): + verify_data_in_form( + data={"csrf_token": None}, + err_message="Missing csrf_token parameter on /instances/new.", + redirect_url="instances", + next=True, + ) + verify_data_in_form( + data={"instance_hostname": None}, + err_message="Missing instance hostname parameter on /instances/new.", + redirect_url="instances", + next=True, + ) + verify_data_in_form( + data={"instance_name": None}, + err_message="Missing instance name parameter on /instances/new.", + redirect_url="instances", + next=True, + ) + + db_config = app.bw_config.get_config(global_only=True, methods=False, filtered_settings=("API_HTTP_PORT", "API_SERVER_NAME")) + + instance = { + "hostname": request.form["instance_hostname"].replace("http://", "").replace("https://", ""), + "name": request.form["instance_name"], + "port": db_config["API_HTTP_PORT"], + "server_name": db_config["API_SERVER_NAME"], + "method": "ui", + } + + for db_instance in app.bw_instances_utils.get_instances(): + if db_instance.hostname == instance["hostname"]: + return handle_error(f"The hostname {instance['hostname']} is already in use.", "instances", True) + + ret = app.db.add_instance(**instance) + if ret: + return handle_error(f"Couldn't create the instance in the database: {ret}", "instances", True) + + return redirect(url_for("loading", next=url_for("instances"), message=f"Creating new instance {instance['hostname']}")) + + +@app.route("/instances/", methods=["DELETE"]) +@login_required +def instances_delete(instance_hostname: str): + verify_data_in_form( + data={"csrf_token": None}, + err_message="Missing csrf_token parameter on /instances/delete.", + redirect_url="instances", + next=True, + ) + + delete_instance = None + for instance in app.bw_instances_utils.get_instances(): + if instance.hostname == instance_hostname: + delete_instance = instance + break + + if not delete_instance: + return handle_error(f"Instance {instance_hostname} not found.", "instances", True) + if delete_instance.method != "ui": + return handle_error(f"Instance {instance_hostname} is not a UI instance.", "instances", True) + + ret = app.db.delete_instance(instance_hostname) + if ret: + return handle_error(f"Couldn't delete the instance in the database: {ret}", "instances", True) + + return redirect(url_for("loading", next=url_for("instances"), message=f"Deleting instance {instance_hostname}")) + + +@app.route("/instances/", methods=["POST"]) +@login_required +def instances_action(action: Literal["ping", "reload", "start", "stop", "restart"]): + verify_data_in_form( + data={"instance_hostname": None, "csrf_token": None}, + err_message="Missing instance hostname parameter on /instances/reload.", + redirect_url="instances", + next=True, + ) + + app.data["RELOADING"] = True + app.data["LAST_RELOAD"] = time() + Thread( + target=manage_bunkerweb, + name=f"Reloading instance {request.form['instance_hostname']}", + args=("instances", request.form["instance_hostname"]), + kwargs={"operation": action, "threaded": True}, + ).start() + + return redirect( + url_for( + "loading", + next=url_for("instances"), + message=(f"{action.title()}ing" if action != "stop" else "Stopping") + " instance", + ) + ) def get_service_data(page_name: str): diff --git a/src/ui/src/instance.py b/src/ui/src/instance.py index 519d3d6c0..b4feef89d 100644 --- a/src/ui/src/instance.py +++ b/src/ui/src/instance.py @@ -145,7 +145,16 @@ def metrics(self, plugin_id) -> Tuple[bool, dict[str, Any]]: def metrics_redis(self) -> Tuple[bool, dict[str, Any]]: return self.apiCaller.send_to_apis("GET", "/redis/stats", response=True) - def ping(self, plugin_id) -> Tuple[bool, dict[str, Any]]: + def ping(self, plugin_id: Optional[str] = None) -> Tuple[bool, dict[str, Any]]: + if not plugin_id: + try: + result = self.apiCaller.send_to_apis("GET", "/ping")[0] + except BaseException as e: + return f"Can't ping {self.hostname}: {e}", {} + + if result: + return f"Instance {self.hostname} is up", {} + return f"Can't ping {self.hostname}", {} return self.apiCaller.send_to_apis("POST", f"/{plugin_id}/ping", response=True) def data(self, plugin_endpoint) -> Tuple[bool, dict[str, Any]]: