From 02a8af2aa7a728980eefce7c2b8007f8d67bc77a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Diot?= Date: Fri, 6 Sep 2024 16:09:07 +0200 Subject: [PATCH] Made a lot of improvements in web UI + have a working configuration save for services and global config --- src/ui/app/dependencies.py | 2 +- src/ui/app/models/config.py | 55 ++- src/ui/app/models/totp.py | 2 + src/ui/app/routes/configs.py | 2 +- src/ui/app/routes/global_config.py | 92 ++--- src/ui/app/routes/instances.py | 1 + src/ui/app/routes/plugins.py | 2 +- src/ui/app/routes/services.py | 171 ++++++++- src/ui/app/routes/setup.py | 2 +- src/ui/app/routes/utils.py | 64 +--- src/ui/app/static/css/main.css | 31 ++ src/ui/app/static/js/pages/instances.js | 10 +- src/ui/app/static/js/plugins-settings.js | 340 ++++++++++++++++-- src/ui/app/templates/breadcrumb.html | 8 +- src/ui/app/templates/dashboard.html | 73 +++- src/ui/app/templates/flash.html | 4 +- src/ui/app/templates/menu.html | 104 +++--- .../templates/models/checkbox_setting.html | 11 +- .../app/templates/models/input_setting.html | 11 +- .../templates/models/plugins_settings.html | 332 +++++++++-------- .../app/templates/models/select_setting.html | 11 +- src/ui/app/utils.py | 33 +- src/ui/app/widgets/__init__.py | 0 src/ui/main.py | 7 +- 24 files changed, 961 insertions(+), 407 deletions(-) delete mode 100644 src/ui/app/widgets/__init__.py diff --git a/src/ui/app/dependencies.py b/src/ui/app/dependencies.py index 1e2168aea..7bfc9dab6 100644 --- a/src/ui/app/dependencies.py +++ b/src/ui/app/dependencies.py @@ -10,5 +10,5 @@ DB = UIDatabase(getLogger("UI"), log=False) DATA = UIData(Path(sep, "var", "tmp", "bunkerweb").joinpath("ui_data.json")) -BW_CONFIG = Config(DB) +BW_CONFIG = Config(DB, data=DATA) BW_INSTANCES_UTILS = InstancesUtils(DB) diff --git a/src/ui/app/models/config.py b/src/ui/app/models/config.py index 3382dc1f1..3e938249d 100644 --- a/src/ui/app/models/config.py +++ b/src/ui/app/models/config.py @@ -9,11 +9,14 @@ from re import error as RegexError, search as re_search from typing import List, Literal, Optional, Set, Tuple, Union +from app.utils import get_blacklisted_settings + class Config: - def __init__(self, db) -> None: + def __init__(self, db, data) -> None: self.__settings = json_loads(Path(sep, "usr", "share", "bunkerweb", "settings.json").read_text(encoding="utf-8")) self.__db = db + self.__data = data def __gen_conf( self, global_conf: dict, services_conf: list[dict], *, check_changes: bool = True, changed_service: Optional[str] = None @@ -104,7 +107,7 @@ def get_services(self, methods: bool = True, with_drafts: bool = False) -> list[ """ return self.__db.get_services_settings(methods=methods, with_drafts=with_drafts) - def check_variables(self, variables: dict, config: dict) -> dict: + def check_variables(self, variables: dict, config: dict, *, global_config: bool = False, threaded: bool = False) -> dict: """Testify that the variables passed are valid Parameters @@ -117,32 +120,41 @@ def check_variables(self, variables: dict, config: dict) -> dict: int Return the error code """ + self.__data.load_from_file() plugins_settings = self.get_plugins_settings() for k, v in variables.copy().items(): check = False - if k.endswith("SCHEMA"): - variables.pop(k) - continue - if k in plugins_settings: setting = k else: setting = k[0 : k.rfind("_")] # noqa: E203 if setting not in plugins_settings or "multiple" not in plugins_settings[setting]: - flash(f"Variable {k} is not valid.", "error") + content = f"Variable {k} is not valid." + if threaded: + self.__data["TO_FLASH"].append({"content": content, "type": "error"}) + else: + flash(content, "error") variables.pop(k) continue - if setting in ("AUTOCONF_MODE", "SWARM_MODE", "KUBERNETES_MODE", "IS_LOADING", "IS_DRAFT"): - flash(f"Variable {k} is not editable, ignoring it", "error") + if setting in get_blacklisted_settings(global_config): + message = f"Variable {k} is not editable, ignoring it" + if threaded: + self.__data["TO_FLASH"].append({"content": message, "type": "error"}) + else: + flash(message, "error") variables.pop(k) continue elif setting not in config and plugins_settings[setting]["default"] == v: variables.pop(k) continue elif config[setting]["method"] not in ("default", "ui"): - flash(f"Variable {k} is not editable as is it managed by the {config[setting]['method']}, ignoring it", "error") + message = f"Variable {k} is not editable as is it managed by the {config[setting]['method']}, ignoring it" + if threaded: + self.__data["TO_FLASH"].append({"content": message, "type": "error"}) + else: + flash(message, "error") variables.pop(k) continue @@ -150,14 +162,33 @@ def check_variables(self, variables: dict, config: dict) -> dict: if re_search(plugins_settings[setting]["regex"], v): check = True except RegexError as e: - flash(f"Invalid regex for setting {setting} : {plugins_settings[setting]['regex']}, ignoring regex check:{e}", "error") + message = f"Invalid regex for setting {setting} : {plugins_settings[setting]['regex']}, ignoring regex check:{e}" + if threaded: + self.__data["TO_FLASH"].append({"content": message, "type": "error"}) + else: + flash(message, "error") variables.pop(k) continue if not check: - flash(f"Variable {k} is not valid.", "error") + message = f"Variable {k} is not valid." + if threaded: + self.__data["TO_FLASH"].append({"content": message, "type": "error"}) + else: + flash(message, "error") variables.pop(k) + for k in config: + if k in plugins_settings: + continue + setting = k[0 : k.rfind("_")] # noqa: E203 + + if setting not in plugins_settings or "multiple" not in plugins_settings[setting]: + continue + + if k not in variables: + variables[k] = plugins_settings[setting]["default"] + return variables def new_service(self, variables: dict, is_draft: bool = False) -> Tuple[str, int]: diff --git a/src/ui/app/models/totp.py b/src/ui/app/models/totp.py index 7350ac363..7df6ac2a3 100644 --- a/src/ui/app/models/totp.py +++ b/src/ui/app/models/totp.py @@ -95,10 +95,12 @@ def generate_qrcode(self, username: str, totp: str) -> str: def get_last_counter(self, user: Users) -> Optional[int]: """Fetch stored last_counter from cache.""" + DATA.load_from_file() return DATA.get("totp_last_counter", {}).get(user.get_id()) def set_last_counter(self, user: Users, tmatch: TotpMatch) -> None: """Cache last_counter.""" + DATA.load_from_file() if "totp_last_counter" not in DATA: DATA["totp_last_counter"] = {} DATA["totp_last_counter"][user.get_id()] = tmatch.counter diff --git a/src/ui/app/routes/configs.py b/src/ui/app/routes/configs.py index dc774c873..14598781e 100644 --- a/src/ui/app/routes/configs.py +++ b/src/ui/app/routes/configs.py @@ -5,7 +5,7 @@ from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import login_required -from app.dependencies import BW_CONFIG, DATA, DB +from app.dependencies import BW_CONFIG, DATA, DB # TODO: remember about DATA.load_from_file() from app.utils import LOGGER, PLUGIN_NAME_RX, path_to_dict from app.routes.utils import handle_error, verify_data_in_form diff --git a/src/ui/app/routes/global_config.py b/src/ui/app/routes/global_config.py index b1fd662fa..76a619aba 100644 --- a/src/ui/app/routes/global_config.py +++ b/src/ui/app/routes/global_config.py @@ -1,6 +1,7 @@ from contextlib import suppress from threading import Thread from time import time +from typing import Dict from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import login_required @@ -19,62 +20,66 @@ def global_config_page(): if request.method == "POST": if DB.readonly: return handle_error("Database is in read-only mode", "global_config") + DATA.load_from_file() # Check variables variables = request.form.to_dict().copy() del variables["csrf_token"] - # Edit check fields and remove already existing ones - config = DB.get_config(methods=True, with_drafts=True) - services = config["SERVER_NAME"]["value"].split(" ") - for variable, value in variables.copy().items(): - setting = config.get(variable, {"value": None, "global": True}) - if setting["global"] and value == setting["value"]: - del variables[variable] - continue - - variables = BW_CONFIG.check_variables(variables, config) - - if not variables: - return handle_error("The global configuration was not edited because no values were changed.", "global_config", True) - - for variable, value in variables.copy().items(): - for service in services: - setting = config.get(f"{service}_{variable}", None) - if setting and setting["global"] and (setting["value"] != value or setting["value"] == config.get(variable, {"value": None})["value"]): - variables[f"{service}_{variable}"] = value - - db_metadata = DB.get_metadata() - - def update_global_config(threaded: bool = False): + def update_global_config(variables: Dict[str, str], threaded: bool = False): wait_applying() - manage_bunkerweb("global_config", variables, threaded=threaded) - - if "PRO_LICENSE_KEY" in variables: - DATA["PRO_LOADING"] = True + # Edit check fields and remove already existing ones + config = DB.get_config(methods=True, with_drafts=True) + services = config["SERVER_NAME"]["value"].split(" ") + for variable, value in variables.copy().items(): + setting = config.get(variable, {"value": None, "global": True}) + if setting["global"] and value == setting["value"]: + del variables[variable] + continue + + variables = BW_CONFIG.check_variables(variables, config, global_config=True, threaded=threaded) + + if not variables: + content = "The global configuration was not edited because no values were changed." + if threaded: + DATA["TO_FLASH"].append({"content": content, "type": "warning"}) + else: + flash(content, "warning") + DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) + return + + if "PRO_LICENSE_KEY" in variables: + DATA["PRO_LOADING"] = True + + for variable, value in variables.copy().items(): + for service in services: + setting = config.get(f"{service}_{variable}", None) + if setting and setting["global"] and (setting["value"] != value or setting["value"] == config.get(variable, {"value": None})["value"]): + variables[f"{service}_{variable}"] = value + + with suppress(BaseException): + if config["PRO_LICENSE_KEY"]["value"] != variables["PRO_LICENSE_KEY"]: + if threaded: + DATA["TO_FLASH"].append({"content": "Checking license key to upgrade.", "type": "success"}) + else: + flash("Checking license key to upgrade.", "success") - if any( - v - for k, v in db_metadata.items() - if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed") - ): - DATA["RELOADING"] = True - DATA["LAST_RELOAD"] = time() - Thread(target=update_global_config, args=(True,)).start() - else: - update_global_config() + manage_bunkerweb("global_config", variables, threaded=threaded) - DATA["CONFIG_CHANGED"] = True + DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True}) + Thread(target=update_global_config, args=(variables, True)).start() - with suppress(BaseException): - if config["PRO_LICENSE_KEY"]["value"] != variables["PRO_LICENSE_KEY"]: - flash("Checking license key to upgrade.", "success") + arguments = {} + if request.args.get("keywords"): + arguments["keywords"] = request.args["keywords"] + if request.args.get("type", "all") != "all": + arguments["type"] = request.args["type"] return redirect( url_for( "loading", - next=url_for("global_config.global_config_page"), + next=url_for("global_config.global_config_page") + f"?{'&'.join([f'{k}={v}' for k, v in arguments.items()])}", message="Saving global configuration", ) ) @@ -82,5 +87,4 @@ def update_global_config(threaded: bool = False): keywords = request.args.get("keywords", "") search_type = request.args.get("type", "all") global_config = DB.get_config(global_only=True, methods=True) - plugins = BW_CONFIG.get_plugins() - return render_template("global_config.html", config=global_config, plugins=plugins, keywords=keywords, type=search_type) + return render_template("global_config.html", config=global_config, keywords=keywords, type=search_type) diff --git a/src/ui/app/routes/instances.py b/src/ui/app/routes/instances.py index 89aad647a..0a29e776f 100644 --- a/src/ui/app/routes/instances.py +++ b/src/ui/app/routes/instances.py @@ -75,6 +75,7 @@ def instances_action(action: Literal["ping", "reload", "stop", "delete"]): # TO instances = request.form["instances"].split(",") if not instances: return handle_error("No instances selected.", "instances", True) + DATA.load_from_file() if action == "ping": succeed = [] diff --git a/src/ui/app/routes/plugins.py b/src/ui/app/routes/plugins.py index 17bb046c9..b67f86fd7 100644 --- a/src/ui/app/routes/plugins.py +++ b/src/ui/app/routes/plugins.py @@ -21,7 +21,7 @@ from common_utils import bytes_hash # type: ignore -from app.dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DATA, DB +from app.dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DATA, DB # TODO: remember about DATA.load_from_file() from app.utils import LOGGER, PLUGIN_NAME_RX, TMP_DIR from app.routes.utils import PLUGIN_ID_RX, PLUGIN_KEYS, error_message, handle_error, verify_data_in_form, wait_applying diff --git a/src/ui/app/routes/services.py b/src/ui/app/routes/services.py index 692da5fd2..bd8c7457d 100644 --- a/src/ui/app/routes/services.py +++ b/src/ui/app/routes/services.py @@ -1,9 +1,12 @@ -from flask import Blueprint, Response, redirect, render_template, request, url_for +from threading import Thread +from time import time +from typing import Dict +from flask import Blueprint, flash, redirect, render_template, request, url_for from flask_login import login_required -from app.dependencies import BW_CONFIG, DB +from app.dependencies import BW_CONFIG, DATA, DB -from app.routes.utils import get_service_data, handle_error, update_service +from app.routes.utils import handle_error, manage_bunkerweb, wait_applying services = Blueprint("services", __name__) @@ -11,15 +14,15 @@ @services.route("/services", methods=["GET", "POST"]) @login_required def services_page(): - if request.method == "POST": + if request.method == "POST": # TODO: Handle creation and deletion of services if DB.readonly: return handle_error("Database is in read-only mode", "services") - config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode = get_service_data("services") + # config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode = get_service_data("services") - message = update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged) + # message = update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged) - return redirect(url_for("loading", next=url_for("services.services_page"), message=message)) + # return redirect(url_for("loading", next=url_for("services.services_page"), message=message)) return render_template("services.html") # TODO @@ -28,11 +31,157 @@ def services_page(): @login_required def services_service_page(service: str): services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ") - if service not in services: - return Response("Service not found", status=404) + service_exists = service in services + + if request.method == "POST": + if DB.readonly: + return handle_error("Database is in read-only mode", "services") + DATA.load_from_file() + + # Check variables + variables = request.form.to_dict().copy() + del variables["csrf_token"] + + mode = request.args.get("mode", "easy") + is_draft = variables.get("IS_DRAFT", "no") == "yes" + + def update_service(variables: Dict[str, str], is_draft: bool, threaded: bool = False): # TODO: handle easy and raw modes + wait_applying() + + # Edit check fields and remove already existing ones + if service_exists: + config = DB.get_config(methods=True, with_drafts=True, service=service) + else: + config = DB.get_config(methods=True, with_drafts=True) + was_draft = config.get(f"{service}_IS_DRAFT", {"value": "no"})["value"] == "yes" + + old_server_name = variables.pop("OLD_SERVER_NAME", "") + + # Edit check fields and remove already existing ones + for variable, value in variables.copy().items(): + if variable != "SERVER_NAME" and value == config.get(f"{service}_{variable}", {"value": None})["value"]: + del variables[variable] + + variables = BW_CONFIG.check_variables(variables, config, threaded=threaded) + + if was_draft == is_draft and not variables: + content = f"The service {service} was not edited because no values were changed." + if threaded: + DATA["TO_FLASH"].append({"content": content, "type": "warning"}) + else: + flash(content, "warning") + DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) + return + + if "SERVER_NAME" not in variables: + variables["SERVER_NAME"] = old_server_name + + manage_bunkerweb( + "services", + variables, + old_server_name, + operation="edit" if service_exists else "new", + is_draft=is_draft, + was_draft=was_draft, + threaded=threaded, + ) + + DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True}) + Thread(target=update_service, args=(variables, is_draft, True)).start() + + arguments = {} + if mode != "easy": + arguments["mode"] = mode + if request.args.get("keywords"): + arguments["keywords"] = request.args["keywords"] + if request.args.get("type", "all") != "all": + arguments["type"] = request.args["type"] + + return redirect( + url_for( + "loading", + next=url_for( + "services.services_service_page", + service=service, + ) + + f"?{'&'.join([f'{k}={v}' for k, v in arguments.items()])}", + message=f"Saving configuration for {'draft ' if is_draft else ''}service {service}", + ) + ) + + services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ") + if not service_exists: + db_config = DB.get_config(global_only=True, methods=True) + return render_template("service_settings.html", config=db_config) + mode = request.args.get("mode", "easy") keywords = request.args.get("keywords", "") search_type = request.args.get("type", "all") db_config = DB.get_config(methods=True, with_drafts=True, service=service) - plugins = BW_CONFIG.get_plugins() - return render_template("service_settings.html", config=db_config, plugins=plugins, mode=mode, keywords=keywords, type=search_type) + return render_template( + "service_settings.html", + config=db_config, + mode=mode, + keywords=keywords, + type=search_type, + ) + + +# def update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged): +# if request.form["operation"] == "edit": +# if is_draft_unchanged and len(variables) == 1 and "SERVER_NAME" in variables and server_name == old_server_name: +# return handle_error("The service was not edited because no values were changed.", "services", True) + +# if request.form["operation"] == "new" and not variables: +# return handle_error("The service was not created because all values had the default value.", "services", True) + +# # Delete +# if request.form["operation"] == "delete": + +# is_service = BW_CONFIG.check_variables({"SERVER_NAME": request.form["SERVER_NAME"]}, config) + +# if not is_service: +# error_message(f"Error while deleting the service {request.form['SERVER_NAME']}") + +# if config.get(f"{request.form['SERVER_NAME'].split(' ')[0]}_SERVER_NAME", {"method": "scheduler"})["method"] != "ui": +# return handle_error("The service cannot be deleted because it has not been created with the UI.", "services", True) + +# db_metadata = DB.get_metadata() + +# def update_services(threaded: bool = False): +# wait_applying() + +# manage_bunkerweb( +# "services", +# variables, +# old_server_name, +# variables.get("SERVER_NAME", ""), +# operation=operation, +# is_draft=is_draft, +# was_draft=was_draft, +# threaded=threaded, +# ) + +# if any( +# v +# for k, v in db_metadata.items() +# if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed") +# ): +# DATA["RELOADING"] = True +# DATA["LAST_RELOAD"] = time() +# Thread(target=update_services, args=(True,)).start() +# else: +# update_services() + +# DATA["CONFIG_CHANGED"] = True + +# message = "" + +# if request.form["operation"] == "new": +# message = f"Creating {'draft ' if is_draft else ''}service {variables.get('SERVER_NAME', '').split(' ')[0]}" +# elif request.form["operation"] == "edit": +# message = f"Saving configuration for {'draft ' if is_draft else ''}service {old_server_name.split(' ')[0]}" +# elif request.form["operation"] == "delete": +# message = f"Deleting {'draft ' if was_draft and is_draft else ''}service {request.form.get('SERVER_NAME', '').split(' ')[0]}" + +# return message diff --git a/src/ui/app/routes/setup.py b/src/ui/app/routes/setup.py index 782911fa7..a79741eb2 100644 --- a/src/ui/app/routes/setup.py +++ b/src/ui/app/routes/setup.py @@ -6,7 +6,7 @@ from flask import Blueprint, Response, flash, redirect, render_template, request, url_for -from app.dependencies import BW_CONFIG, DATA, DB +from app.dependencies import BW_CONFIG, DATA, DB # TODO: remember about DATA.load_from_file() from app.utils import USER_PASSWORD_RX, gen_password_hash from app.routes.utils import REVERSE_PROXY_PATH, handle_error, manage_bunkerweb diff --git a/src/ui/app/routes/utils.py b/src/ui/app/routes/utils.py index 061aaf729..a89c1cc8b 100644 --- a/src/ui/app/routes/utils.py +++ b/src/ui/app/routes/utils.py @@ -3,8 +3,7 @@ from datetime import datetime from functools import wraps from io import BytesIO -from threading import Thread -from time import sleep, time +from time import sleep from typing import Any, Dict, Optional, Tuple, Union from flask import Response, flash, redirect, request, session, url_for @@ -49,6 +48,7 @@ def wait_applying(): def manage_bunkerweb(method: str, *args, operation: str = "reloads", is_draft: bool = False, was_draft: bool = False, threaded: bool = False) -> int: # Do the operation error = 0 + DATA.load_from_file() if "TO_FLASH" not in DATA: DATA["TO_FLASH"] = [] @@ -311,66 +311,6 @@ def get_service_data(page_name: str): return config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged, mode -def update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged): - if request.form["operation"] == "edit": - if is_draft_unchanged and len(variables) == 1 and "SERVER_NAME" in variables and server_name == old_server_name: - return handle_error("The service was not edited because no values were changed.", "services", True) - - if request.form["operation"] == "new" and not variables: - return handle_error("The service was not created because all values had the default value.", "services", True) - - # Delete - if request.form["operation"] == "delete": - - is_service = BW_CONFIG.check_variables({"SERVER_NAME": request.form["SERVER_NAME"]}, config) - - if not is_service: - error_message(f"Error while deleting the service {request.form['SERVER_NAME']}") - - if config.get(f"{request.form['SERVER_NAME'].split(' ')[0]}_SERVER_NAME", {"method": "scheduler"})["method"] != "ui": - return handle_error("The service cannot be deleted because it has not been created with the UI.", "services", True) - - db_metadata = DB.get_metadata() - - def update_services(threaded: bool = False): - wait_applying() - - manage_bunkerweb( - "services", - variables, - old_server_name, - variables.get("SERVER_NAME", ""), - operation=operation, - is_draft=is_draft, - was_draft=was_draft, - threaded=threaded, - ) - - if any( - v - for k, v in db_metadata.items() - if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed") - ): - DATA["RELOADING"] = True - DATA["LAST_RELOAD"] = time() - Thread(target=update_services, args=(True,)).start() - else: - update_services() - - DATA["CONFIG_CHANGED"] = True - - message = "" - - if request.form["operation"] == "new": - message = f"Creating {'draft ' if is_draft else ''}service {variables.get('SERVER_NAME', '').split(' ')[0]}" - elif request.form["operation"] == "edit": - message = f"Saving configuration for {'draft ' if is_draft else ''}service {old_server_name.split(' ')[0]}" - elif request.form["operation"] == "delete": - message = f"Deleting {'draft ' if was_draft and is_draft else ''}service {request.form.get('SERVER_NAME', '').split(' ')[0]}" - - return message - - def cors_required(f): @wraps(f) def decorated_function(*args, **kwargs): diff --git a/src/ui/app/static/css/main.css b/src/ui/app/static/css/main.css index 77bdc41da..fe15c05dd 100644 --- a/src/ui/app/static/css/main.css +++ b/src/ui/app/static/css/main.css @@ -290,6 +290,23 @@ td.highlight { background-color: var(--bs-bw-green); } +.btn-text-bw-green, +.btn-text-bw-green:hover { + color: var(--bs-bw-green) !important; +} + +.btn-text-secondary, +.btn-text-secondary:hover { + color: var(--bs-secondary) !important; +} + +.btn-text-danger, +.btn-text-danger.disabled, +.btn-text-danger:hover { + color: var(--bs-danger) !important; + border: none; +} + .col.form-floating > .form-control-sm, .col-auto.form-floating > .form-select.form-select-sm { height: calc(1.9em + 0.9rem + 1px); @@ -398,3 +415,17 @@ a.badge:hover { .setting-checkbox-label { font-size: calc(var(--bs-body-font-size) * 0.85); } + +.sticky-card { + position: sticky; + background-color: rgba(255, 255, 255, 0.88) !important; + backdrop-filter: saturate(200%) blur(6px); + top: 85px; + z-index: 1000; +} + +#floating-modes-menu { + bottom: 2.1rem !important; + left: 1.5rem !important; + z-index: 1080; +} diff --git a/src/ui/app/static/js/pages/instances.js b/src/ui/app/static/js/pages/instances.js index d3fd2f6ce..ea77a010f 100644 --- a/src/ui/app/static/js/pages/instances.js +++ b/src/ui/app/static/js/pages/instances.js @@ -154,7 +154,7 @@ $(document).ready(function () { // Create a FormData object const formData = new FormData(); - formData.append("csrf_token", $("#csrf_token").val()); // Add the CSRF token + formData.append("csrf_token", $("#csrf_token").val()); // Add the CSRF token // TODO: find a way to ignore CSRF token formData.append("instances", instances.join(",")); // Add the instances // Send the form data using $.ajax @@ -224,21 +224,21 @@ $(document).ready(function () { } // Create a form element using jQuery and set its attributes - const $form = $("
", { + const form = $("", { method: "POST", action: `${window.location.pathname}/${action}`, class: "visually-hidden", }); // Add CSRF token and instances as hidden inputs - $form.append( + form.append( $("", { type: "hidden", name: "csrf_token", value: $("#csrf_token").val(), }), ); - $form.append( + form.append( $("", { type: "hidden", name: "instances", @@ -247,7 +247,7 @@ $(document).ready(function () { ); // Append the form to the body and submit it - $form.appendTo("body").submit(); + form.appendTo("body").submit(); }, }; diff --git a/src/ui/app/static/js/plugins-settings.js b/src/ui/app/static/js/plugins-settings.js index cfe8dd4c1..bd506e827 100644 --- a/src/ui/app/static/js/plugins-settings.js +++ b/src/ui/app/static/js/plugins-settings.js @@ -5,7 +5,7 @@ $(document).ready(() => { let currentKeywords = ""; const pluginDropdownItems = $("#plugins-dropdown-menu li.nav-item"); - const updateUrlParams = (params) => { + const updateUrlParams = (params, removeHash = false) => { // Create a new URL based on the current location (this keeps both the search params and the hash) const newUrl = new URL(window.location.href); @@ -20,7 +20,12 @@ $(document).ready(() => { // Update the search params of the URL newUrl.search = searchParams.toString(); - // Push the updated URL (this keeps the hash and the merged search params) + // Optionally remove the hash from the URL + if (removeHash) { + newUrl.hash = ""; + } + + // Push the updated URL (this keeps or removes the hash and updates the search params) history.pushState(params, document.title, newUrl.toString()); }; @@ -88,7 +93,8 @@ $(document).ready(() => { const params = {}; if (currentType !== "all") params.type = currentType; if (currentMode !== "easy") params.mode = currentMode; - updateUrlParams(params); + // Call updateUrlParams with `removeHash = true` to remove the hash + updateUrlParams(params, true); } else { window.location.hash = currentPlugin; } @@ -114,7 +120,28 @@ $(document).ready(() => { $(this).removeClass("is-valid"); }); - $(".show-multiple").on("click", function () { + $("#plugin-type-select").on("change", function () { + currentType = $(this).val(); + const params = currentType === "all" ? {} : { type: currentType }; + + updateUrlParams(params); + + pluginDropdownItems.each(function () { + $(this).toggle( + currentType === "all" || $(this).data("type") === currentType, + ); + }); + + const currentPane = $('div[id^="navs-plugins-"].active').first(); + if (currentPane.data("type") !== currentType) { + $(`#plugins-dropdown-menu li.nav-item[data-type="${currentType}"]`) + .first() + .find("button") + .tab("show"); + } + }); + + $(document).on("click", ".show-multiple", function () { const toggleText = $(this).text().trim() === "SHOW" ? "HIDE" : "SHOW"; $(this).html( ` + +`, + ); + + // Append the cloned element to the container + $(`#${multipleId}`).append(multipleClone); + + // Reinitialize Bootstrap tooltips for the newly added clone + multipleClone.find('[data-bs-toggle="tooltip"]').tooltip(); + + // Update the data-bs-target and aria-controls attributes of the show-multiple button + const showMultiple = multipleClone.find(".show-multiple"); + showMultiple + .attr("data-bs-target", `#${cloneId}`) + .attr("aria-controls", cloneId); + if (showMultiple.text().trim() === "SHOW") showMultiple.trigger("click"); + + // Scroll to the newly added element + multipleClone.focus()[0].scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }); + + $(document).on("click", ".remove-multiple", function () { + const multipleId = $(this).attr("id").replace("remove-", ""); + const multiple = $(`#${multipleId}`); + + // Check if any input/select is disabled, and exit early if so + let disabled = false; + multiple.find("input, select").each(function () { + if ($(this).prop("disabled")) { + disabled = true; + return false; // Exit the loop early + } + }); + + if (disabled) return; + + const elementToRemove = multiple.parent(); + + // Ensure the element has the 'collapse' class + if (!elementToRemove.hasClass("collapse")) { + elementToRemove.addClass("collapse show"); + } + + // Initialize Bootstrap Collapse for the element + const bsCollapse = new bootstrap.Collapse(elementToRemove, { + toggle: false, // Ensure we only collapse, not toggle + }); + + // Start the collapsing animation and adjust padding + bsCollapse.hide(); + elementToRemove.removeClass("pt-2 pb-2").addClass("pt-0 pb-0"); + + // Remove the element after collapse transition completes + elementToRemove.on("hidden.bs.collapse", function () { + setTimeout(() => { + $(this).remove(); // Remove the element after collapse + }, 60); + }); + + // Update all next elements' IDs and names + elementToRemove.nextAll().each(function () { + const nextId = $(this).find(".multiple-collapse").attr("id"); + const nextSuffix = parseInt( + nextId.substring(nextId.lastIndexOf("-") + 1), + 10, + ); + const newSuffix = nextSuffix - 1; + const newId = nextId.replace(`-${nextSuffix}`, `-${newSuffix}`); + + // Update the ID of the next element + $(this).find(".multiple-collapse").attr("id", newId); + + const multipleTitle = $(this).find("h6"); + multipleTitle.text(function () { + return $(this).text().replace(` #${nextSuffix}`, ` #${newSuffix}`); + }); + + // Update the input/select name and corresponding label + $(this) + .find("input, select") + .each(function () { + const newName = $(this) + .attr("name") + .replace(`_${nextSuffix}`, `_${newSuffix}`); + $(this).attr("name", newName); + + // Find the associated label and update its 'for' attribute and text + const settingLabel = $(`label[for="${$(this).attr("id")}"]`); + if (settingLabel.length) { + settingLabel.attr("for", newId).text(function () { + return $(this).text().replace(`_${nextSuffix}`, `_${newSuffix}`); + }); + } + }); + + // Update the data-bs-target and aria-controls of the show-multiple button + const showMultiple = $(this).find(".show-multiple"); + showMultiple + .attr("data-bs-target", `#${newId}`) + .attr("aria-controls", newId); + + const removeMultiple = $(this).find(".remove-multiple"); + removeMultiple.attr("id", `remove-${newId}`); + }); + }); + + $("#save-settings").on("click", function () { + const form = $("", { + method: "POST", + action: window.location.href, + class: "visually-hidden", + }); + + form.append( + $("", { + type: "hidden", + name: "csrf_token", + value: $("#csrf_token").val(), + }), + ); + $("div[id^='navs-plugins-']") + .find("input, select") + .each(function () { + const settingName = $(this).attr("name"); + const settingType = $(this).attr("type"); + const originalValue = $(this).data("original"); + var settingValue = $(this).val(); + + if ($(this).is("select")) { + settingValue = $(this).find("option:selected").val(); + } else if (settingType === "checkbox") { + settingValue = $(this).prop("checked") ? "yes" : "no"; + } + + if ( + $(this).attr("id") && + !$(this).attr("id").startsWith("multiple-") && + settingValue == originalValue + ) + return; + + form.append( + $("", { + type: "hidden", + name: settingName, + value: settingValue, + }), + ); + }); + + if (form.children().length < 2) { + alert("No changes detected."); + return; } + form.appendTo("body").submit(); }); $('div[id^="multiple-"]') .filter(function () { - return !/^multiple-.*-\d+$/.test($(this).attr("id")); + return /^multiple-.*-\d+$/.test($(this).attr("id")); }) .each(function () { let defaultValues = true; + let disabled = false; $(this) .find("input, select") .each(function () { const type = $(this).attr("type"); const defaultVal = $(this).data("default"); - const isChecked = - type === "checkbox" && - $(this).prop("checked") === (defaultVal === "yes"); - const isMatchingValue = - type !== "checkbox" && $(this).val() === defaultVal; - if (!isChecked && !isMatchingValue) defaultValues = false; + if ($(this).prop("disabled")) { + disabled = true; + } + + // Check for select element + if ($(this).is("select")) { + const selectedVal = $(this).find("option:selected").val(); + if (selectedVal != defaultVal) { + defaultValues = false; + } + } else if (type === "checkbox") { + const isChecked = + $(this).prop("checked") === (defaultVal === "yes"); + if (!isChecked) { + defaultValues = false; + } + } else { + const isMatchingValue = $(this).val() == defaultVal; + if (!isMatchingValue) { + defaultValues = false; + } + } }); if (defaultValues) $(`#show-${$(this).attr("id")}`).trigger("click"); - }); + if (disabled && $(`#remove-${$(this).attr("id")}`).length) { + $(`#remove-${$(this).attr("id")}`).addClass("disabled"); + $(`#remove-${$(this).attr("id")}`) + .parent() + .attr( + "title", + "Cannot remove because one or more settings are disabled", + ); - $("#plugin-type-select").on("change", function () { - currentType = $(this).val(); - const params = currentType === "all" ? {} : { type: currentType }; - - updateUrlParams(params); - - pluginDropdownItems.each(function () { - $(this).toggle( - currentType === "all" || $(this).data("type") === currentType, - ); + new bootstrap.Tooltip( + $(`#remove-${$(this).attr("id")}`) + .parent() + .get(0), + { + placement: "top", + }, + ); + } }); - const currentPane = $('div[id^="navs-plugins-"].active').first(); - if (currentPane.data("type") !== currentType) { - $(`#plugins-dropdown-menu li.nav-item[data-type="${currentType}"]`) - .first() - .find("button") - .tab("show"); + var hasExternalPlugins = false; + var hasProPlugins = false; + pluginDropdownItems.each(function () { + const type = $(this).data("type"); + if (type === "external") { + hasExternalPlugins = true; + } else if (type === "pro") { + hasProPlugins = true; } }); + if (!hasExternalPlugins && !hasProPlugins) { + $("#plugin-type-select").parent().remove(); + } else if (!hasExternalPlugins) { + $("#plugin-type-select option[value='external']").remove(); + } else if (!hasProPlugins) { + $("#plugin-type-select option[value='pro']").remove(); + } + const hash = window.location.hash; if (hash) { const targetTab = $( diff --git a/src/ui/app/templates/breadcrumb.html b/src/ui/app/templates/breadcrumb.html index 9fc071a11..fb3f4802a 100644 --- a/src/ui/app/templates/breadcrumb.html +++ b/src/ui/app/templates/breadcrumb.html @@ -1,12 +1,8 @@