diff --git a/src/ui/app/models/config.py b/src/ui/app/models/config.py index c1e3ad640..034e03444 100644 --- a/src/ui/app/models/config.py +++ b/src/ui/app/models/config.py @@ -58,23 +58,18 @@ class Config: def get_plugins_settings(self) -> dict: return { - **{k: v for x in self.get_plugins() for k, v in x["settings"].items()}, + **{k: v for x in self.get_plugins().values() for k, v in x["settings"].items()}, **self.__settings, } - def get_plugins(self, *, _type: Literal["all", "external", "ui", "pro"] = "all", with_data: bool = False) -> List[dict]: - plugins = self.__db.get_plugins(_type=_type, with_data=with_data) - plugins.sort(key=itemgetter("name")) + def get_plugins(self, *, _type: Literal["all", "external", "ui", "pro"] = "all", with_data: bool = False) -> dict: + db_plugins = self.__db.get_plugins(_type=_type, with_data=with_data) + db_plugins.sort(key=itemgetter("name")) - general_plugin = None - for plugin in plugins.copy(): - if plugin["id"] == "general": - general_plugin = plugin - plugins.remove(plugin) - break + plugins = {"general": {}} - if general_plugin: - plugins.insert(0, general_plugin) + for plugin in db_plugins.copy(): + plugins[plugin.pop("id")] = plugin return plugins diff --git a/src/ui/app/routes/plugins.py b/src/ui/app/routes/plugins.py index b67f86fd7..214439625 100644 --- a/src/ui/app/routes/plugins.py +++ b/src/ui/app/routes/plugins.py @@ -302,7 +302,7 @@ def plugins_page(): def update_plugins(threaded: bool = False): wait_applying() - plugins = BW_CONFIG.get_plugins(_type="ui", with_data=True) + plugins = BW_CONFIG.get_plugins(_type="ui", with_data=True) # TODO: remember that this returns a dict now for plugin in deepcopy(plugins): if plugin["id"] in new_plugins_ids: flash(f"Plugin {plugin['id']} already exists", "error") diff --git a/src/ui/app/routes/services.py b/src/ui/app/routes/services.py index a203ccdf4..3c0326e1b 100644 --- a/src/ui/app/routes/services.py +++ b/src/ui/app/routes/services.py @@ -1,7 +1,7 @@ from re import match from threading import Thread from time import time -from typing import Dict +from typing import Dict, List from flask import Blueprint, Response, redirect, render_template, request, url_for from flask_login import login_required @@ -18,6 +18,86 @@ def services_page(): return render_template("services.html", services=DB.get_services(with_drafts=True)) +@services.route("/services/convert", methods=["POST"]) +@login_required +def services_convert(): + verify_data_in_form( + data={"services": None}, + err_message="Missing services parameter on /services/convert.", + redirect_url="services", + next=True, + ) + verify_data_in_form( + data={"convert_to": None}, + err_message="Missing convert_to parameter on /services/convert.", + redirect_url="services", + next=True, + ) + + services = request.form["services"].split(",") + if not services: + return handle_error("No services selected.", "services", True) + + convert_to = request.form["convert_to"] + if convert_to not in ("online", "draft"): + return handle_error("Invalid convert_to parameter.", "services", True) + DATA.load_from_file() + + def convert_services(services: List[str], convert_to: str): + wait_applying() + + db_services = DB.get_services(with_drafts=True) + services_to_convert = set() + non_ui_services = set() + non_convertible_services = set() + + for db_service in db_services: + if db_service["id"] in services: + if db_service["method"] != "ui": + non_ui_services.add(db_service["id"]) + continue + if db_service["is_draft"] == (convert_to == "draft"): + non_convertible_services.add(db_service["id"]) + continue + services_to_convert.add(db_service["id"]) + + for non_ui_service in non_ui_services: + DATA["TO_FLASH"].append({"content": f"Service {non_ui_service} is not a UI service and will not be converted.", "type": "error"}) + + for non_convertible_service in non_convertible_services: + DATA["TO_FLASH"].append( + {"content": f"Service {non_convertible_service} is already a {convert_to} service and will not be converted.", "type": "error"} + ) + + if not services_to_convert: + DATA["TO_FLASH"].append({"content": "All selected services could not be found, are not UI services or are already converted.", "type": "error"}) + DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) + return + + db_config = DB.get_config(with_drafts=True) + for service in services_to_convert: + db_config[f"{service}_IS_DRAFT"] = "yes" if convert_to == "draft" else "no" + + ret = DB.save_config(db_config, "ui", changed=True) + if isinstance(ret, str): + DATA["TO_FLASH"].append({"content": ret, "type": "error"}) + DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) + return + DATA["TO_FLASH"].append({"content": f"Converted services: {', '.join(services_to_convert)}", "type": "success"}) + DATA["RELOADING"] = False + + DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True}) + Thread(target=convert_services, args=(services, convert_to)).start() + + return redirect( + url_for( + "loading", + next=url_for("services.services_page"), + message=f"Converting service{'s' if len(services) > 1 else ''} {', '.join(services)} to {convert_to}", + ) + ) + + @services.route("/services/delete", methods=["POST"]) @login_required def services_delete(): @@ -32,10 +112,10 @@ def services_delete(): return handle_error("No services selected.", "services", True) DATA.load_from_file() - def delete_services(services): + def delete_services(services: List[str]): wait_applying() - db_config = BW_CONFIG.get_config(methods=False) + db_config = BW_CONFIG.get_config(methods=False, with_drafts=True) db_services = DB.get_services(with_drafts=True) all_drafts = True services_to_delete = set() @@ -71,7 +151,7 @@ def services_delete(): DATA["TO_FLASH"].append({"content": ret, "type": "error"}) DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) return - DATA["TO_FLASH"].append({"content": f"Deleted services {', '.join(services_to_delete)}", "type": "success"}) + DATA["TO_FLASH"].append({"content": f"Deleted services: {', '.join(services_to_delete)}", "type": "success"}) DATA["RELOADING"] = False DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True}) @@ -81,7 +161,7 @@ def services_delete(): url_for( "loading", next=url_for("services.services_page"), - message=f"Deleting services {', '.join(services)}", + message=f"Deleting service{'s' if len(services) > 1 else ''} {', '.join(services)}", ) ) @@ -107,7 +187,7 @@ def services_service_page(service: str): mode = request.args.get("mode", "easy") is_draft = variables.pop("IS_DRAFT", "no") == "yes" - def update_service(service: str, variables: Dict[str, str], is_draft: bool, mode: str): # TODO: handle easy and raw modes + def update_service(service: str, variables: Dict[str, str], is_draft: bool, mode: str): # TODO: handle easy mode wait_applying() # Edit check fields and remove already existing ones @@ -121,15 +201,16 @@ def services_service_page(service: str): ignored_multiples = set() # Edit check fields and remove already existing ones - for variable, value in variables.copy().items(): - if (mode == "raw" or variable != "SERVER_NAME") and value == config.get(variable, {"value": None})["value"]: - if match(r"^.+_\d+$", variable): - ignored_multiples.add(variable) - del variables[variable] + if mode != "easy": + for variable, value in variables.copy().items(): + if (mode == "raw" or variable != "SERVER_NAME") and value == config.get(variable, {"value": None})["value"]: + if match(r"^.+_\d+$", variable): + ignored_multiples.add(variable) + del variables[variable] variables = BW_CONFIG.check_variables(variables, config, ignored_multiples=ignored_multiples, new=service == "new", threaded=True) - if was_draft == is_draft and not variables: + if service != "new" and was_draft == is_draft and not variables: content = f"The service {service} was not edited because no values were changed." DATA["TO_FLASH"].append({"content": content, "type": "warning"}) DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) @@ -181,6 +262,8 @@ def services_service_page(service: str): mode = request.args.get("mode", "easy") search_type = request.args.get("type", "all") + template = request.args.get("template", "high") + db_templates = DB.get_templates() if service == "new": clone = request.args.get("clone", "") if clone: @@ -189,24 +272,30 @@ def services_service_page(service: str): return render_template( "service_settings.html", config=db_config, + templates=db_templates, mode=mode, type=search_type, + current_template=template, ) db_config = DB.get_config(global_only=True, methods=True) return render_template( "service_settings.html", config=db_config, + templates=db_templates, mode=mode, type=search_type, + current_template=template, ) db_config = DB.get_config(methods=True, with_drafts=True, service=service) return render_template( "service_settings.html", config=db_config, + templates=db_templates, mode=mode, type=search_type, + current_template=template, ) diff --git a/src/ui/app/static/css/main.css b/src/ui/app/static/css/main.css index 5d4bab86e..94f9a442e 100644 --- a/src/ui/app/static/css/main.css +++ b/src/ui/app/static/css/main.css @@ -453,3 +453,11 @@ a.badge:hover { background-color: transparent; opacity: 1; /* You can set this to 0 if you want it to fade out */ } + +.template-steps-container { + --bs-breadcrumb-divider: url("../img/bxs-chevron-right.svg"); +} + +.template-steps-container .breadcrumb-item + .breadcrumb-item::before { + padding: 0 1rem 0 1rem; +} diff --git a/src/ui/app/static/img/bxs-chevron-right.svg b/src/ui/app/static/img/bxs-chevron-right.svg new file mode 100644 index 000000000..670e11ad1 --- /dev/null +++ b/src/ui/app/static/img/bxs-chevron-right.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/ui/app/static/js/pages/instances.js b/src/ui/app/static/js/pages/instances.js index 81e55a63b..2e9a76719 100644 --- a/src/ui/app/static/js/pages/instances.js +++ b/src/ui/app/static/js/pages/instances.js @@ -309,7 +309,6 @@ $(document).ready(function () { ], order: [[7, "desc"]], autoFill: false, - colReorder: true, responsive: true, select: { style: "multi+shift", diff --git a/src/ui/app/static/js/pages/services.js b/src/ui/app/static/js/pages/services.js index e7a73cec7..29f6eb7c6 100644 --- a/src/ui/app/static/js/pages/services.js +++ b/src/ui/app/static/js/pages/services.js @@ -293,7 +293,6 @@ $(document).ready(function () { ], order: [[5, "desc"]], autoFill: false, - colReorder: true, responsive: true, select: { style: "multi+shift", diff --git a/src/ui/app/static/js/plugins-settings.js b/src/ui/app/static/js/plugins-settings.js index 4e13714db..f80df5866 100644 --- a/src/ui/app/static/js/plugins-settings.js +++ b/src/ui/app/static/js/plugins-settings.js @@ -1,6 +1,8 @@ $(document).ready(() => { var toastNum = 0; let currentPlugin = "general"; + let usedTemplate = $("#used-template").val(); + let currentTemplate = $("#selected-template").val(); let currentMode = $("#selected-mode").val(); let currentType = $("#selected-type").val(); @@ -9,7 +11,10 @@ $(document).ready(() => { const $pluginTypeSelect = $("#plugin-type-select"); const $pluginKeywordSearch = $("#plugin-keyword-search"); const $pluginDropdownMenu = $("#plugins-dropdown-menu"); - const pluginDropdownItems = $("#plugins-dropdown-menu li.nav-item"); + const $pluginDropdownItems = $("#plugins-dropdown-menu li.nav-item"); + const $templateSearch = $("#template-search"); + const $templateDropdownMenu = $("#templates-dropdown-menu"); + const $templateDropdownItems = $("#templates-dropdown-menu li.nav-item"); const updateUrlParams = (params, removeHash = false) => { const newUrl = new URL(window.location.href); @@ -36,37 +41,64 @@ $(document).ready(() => { // Prepare params for the URL update const params = {}; - if (currentType !== "all") params.type = currentType; + params.mode = currentMode; + if (currentMode === "advanced" && currentType !== "all") + params.type = currentType; + if (currentMode === "easy" && currentTemplate !== "high") + params.template = currentTemplate; // If "easy" is selected, remove the "mode" parameter if (currentMode === "easy") { params.mode = null; // Set mode to null to remove it from the URL - updateUrlParams(params); // Call the function without the hash (keep it intact) + params.type = null; // Remove the type parameter + updateUrlParams(params, true); // Call the function without the hash (keep it intact) } else { // If another mode is selected, update the "mode" parameter - params.mode = currentMode; - updateUrlParams(params); // Keep the mode in the URL + params.template = null; // Remove the template parameter + if (currentMode === "advanced" && currentPlugin !== "general") { + // Update the URL hash to the current plugin (e.g., #plugin-name) + window.location.hash = currentPlugin; + } else if (currentMode === "raw") { + params.type = null; // Remove the type parameter + } + updateUrlParams(params, currentMode === "raw"); // Keep the mode in the URL } }; const handleTabChange = (targetClass) => { - currentPlugin = targetClass.substring(1).replace("navs-plugins-", ""); - // Prepare the params for URL (parameters to be updated in the URL) const params = {}; - if (currentType !== "all") params.type = currentType; if (currentMode !== "easy") params.mode = currentMode; + if (currentType !== "all") params.type = currentType; - // If "general" is selected and a hash exists, remove the hash but keep the parameters - if (currentPlugin === "general" && window.location.hash) { - // Call updateUrlParams with `removeHash = true` to remove the hash fragment - updateUrlParams(params, true); - } else { - // Update the URL hash to the current plugin (e.g., #plugin-name) - window.location.hash = currentPlugin; + if (targetClass.includes("navs-plugins-")) { + currentPlugin = targetClass.substring(1).replace("navs-plugins-", ""); + params.template = null; // Remove the template parameter - // Also update the URL parameters (if any exist) while preserving the hash - updateUrlParams(params); + // If "general" is selected and a hash exists, remove the hash but keep the parameters + if (currentPlugin === "general" && window.location.hash) { + // Call updateUrlParams with `removeHash = true` to remove the hash fragment + updateUrlParams(params, true); + } else { + // Update the URL hash to the current plugin (e.g., #plugin-name) + window.location.hash = currentPlugin; + + // Also update the URL parameters (if any exist) while preserving the hash + updateUrlParams(params); + } + } else if (targetClass.includes("navs-templates-")) { + currentTemplate = targetClass.substring(1).replace("navs-templates-", ""); + params.type = null; // Remove the type parameter + + // If "high" is selected, remove the "template" parameter + if (currentTemplate === "high") { + params.template = null; // Set template to null to remove it from the URL + updateUrlParams(params); // Call the function without the hash (keep it intact) + } else { + // If another template is selected, update the "template" parameter + params.template = currentTemplate; + updateUrlParams(params); // Keep the template in the URL + } } }; @@ -109,7 +141,70 @@ $(document).ready(() => { } }; - const getFormFromSettings = () => { + // Function to validate inputs and display error messages + const validateCurrentStepInputs = (currentStepContainer) => { + let isStepValid = true; + + currentStepContainer.find(".plugin-setting").each(function () { + const $input = $(this); + const value = $input.val().trim(); + const isRequired = $input.prop("required"); + const pattern = $input.attr("pattern"); + const fieldName = + $input.data("field-name") || $input.attr("name") || "This field"; + + let errorMessage = ""; + let isValid = true; + + // Custom error messages + const requiredMessage = + $input.data("required-message") || `${fieldName} is required.`; + const patternMessage = + $input.data("pattern-message") || `Please enter a valid ${fieldName}.`; + + // Check if the field is required and not empty + if (isRequired && value === "") { + errorMessage = requiredMessage; + isValid = false; + } + + // Validate based on pattern if the input is not empty + if (isValid && pattern && value !== "") { + const regex = new RegExp(pattern); + if (!regex.test(value)) { + errorMessage = patternMessage; + isValid = false; + } + } + + // Toggle valid/invalid classes + $input.toggleClass("is-invalid", !isValid); + + // Manage the invalid-feedback element + let $feedback = $input.next(".invalid-feedback"); + if (!$feedback.length) { + $feedback = $('
').insertAfter( + $input, + ); + } + + if (!isValid) { + $feedback.text(errorMessage); + isStepValid = false; + } else { + $feedback.text(""); + } + }); + + if (!isStepValid) { + // Focus the first invalid input + currentStepContainer.find(".is-invalid").first().focus(); + } + + return isStepValid; + }; + + const getFormFromSettings = (elem) => { const form = $("