diff --git a/src/common/db/Database.py b/src/common/db/Database.py index f95eb4cf3..fc738a019 100644 --- a/src/common/db/Database.py +++ b/src/common/db/Database.py @@ -1349,13 +1349,14 @@ class Database: for draft in drafts: if draft not in db_drafts: + current_time = datetime.now().astimezone() if draft not in db_ids: self.logger.debug(f"Adding draft {draft}") - to_put.append(Services(id=draft, method=method, is_draft=True)) + to_put.append(Services(id=draft, method=method, is_draft=True, creation_date=current_time, last_update=current_time)) db_ids[draft] = {"method": method, "is_draft": True} elif method == db_ids[draft]["method"]: self.logger.debug(f"Updating draft {draft}") - session.query(Services).filter(Services.id == draft).update({Services.is_draft: True}) + session.query(Services).filter(Services.id == draft).update({Services.is_draft: True, Services.last_update: current_time}) changed_services = True template = config.get("USE_TEMPLATE", "") @@ -1385,7 +1386,12 @@ class Database: if server_name not in db_ids: self.logger.debug(f"Adding service {server_name}") - to_put.append(Services(id=server_name, method=method, is_draft=server_name in drafts)) + current_time = datetime.now().astimezone() + to_put.append( + Services( + id=server_name, method=method, is_draft=server_name in drafts, creation_date=current_time, last_update=current_time + ) + ) db_ids[server_name] = {"method": method, "is_draft": server_name in drafts} if server_name not in drafts: changed_services = True @@ -1425,6 +1431,7 @@ class Database: self.logger.debug(f"Adding setting {key} for service {server_name}") changed_plugins.add(setting.plugin_id) to_put.append(Services_settings(service_id=server_name, setting_id=key, value=value, suffix=suffix, method=method)) + session.query(Services).filter(Services.id == server_name).update({Services.last_update: datetime.now().astimezone()}) elif ( method == service_setting.method or (service_setting.method not in ("scheduler", "autoconf") and method == "autoconf") ) and service_setting.value != value: @@ -1446,6 +1453,7 @@ class Database: self.logger.debug(f"Updating setting {key} for service {server_name}") query.update({Services_settings.value: value, Services_settings.method: method}) + session.query(Services).filter(Services.id == server_name).update({Services.last_update: datetime.now().astimezone()}) elif setting and original_key not in global_values: global_values.append(original_key) global_value = ( @@ -1502,7 +1510,10 @@ class Database: if not session.query(Services).with_entities(Services.id).filter_by(id=first_server).first(): self.logger.debug(f"Adding service {first_server}") - to_put.append(Services(id=first_server, method=method)) + current_time = datetime.now().astimezone() + to_put.append( + Services(id=first_server, method=method, is_draft=first_server in drafts, creation_date=current_time, last_update=current_time) + ) changed_services = True for key, value in config.items(): @@ -2020,6 +2031,23 @@ class Database: return services + def get_services(self, *, with_drafts: bool = False) -> List[Dict[str, Any]]: + """Get the services from the database""" + with self._db_session() as session: + return [ + { + "id": service.id, + "method": service.method, + "is_draft": service.is_draft, + "creation_date": service.creation_date, + "last_update": service.last_update, + } + for service in session.query(Services).with_entities( + Services.id, Services.method, Services.is_draft, Services.creation_date, Services.last_update + ) + if with_drafts or not service.is_draft + ] + def add_job_run(self, job_name: str, success: bool, start_date: datetime, end_date: Optional[datetime] = None) -> str: """Add a job run.""" with self._db_session() as session: diff --git a/src/common/db/model.py b/src/common/db/model.py index 44528521b..c0d9f187d 100644 --- a/src/common/db/model.py +++ b/src/common/db/model.py @@ -109,6 +109,8 @@ class Services(Base): id = Column(String(64), primary_key=True) method = Column(METHODS_ENUM, nullable=False) is_draft = Column(Boolean, default=False, nullable=False) + creation_date = Column(DateTime(timezone=True), nullable=False) + last_update = Column(DateTime(timezone=True), nullable=False) settings = relationship("Services_settings", back_populates="service", cascade="all") custom_configs = relationship("Custom_configs", back_populates="service", cascade="all") diff --git a/src/ui/app/models/config.py b/src/ui/app/models/config.py index 0a60981a7..c1e3ad640 100644 --- a/src/ui/app/models/config.py +++ b/src/ui/app/models/config.py @@ -18,7 +18,7 @@ class Config: self.__db = db self.__data = data - def __gen_conf( + def gen_conf( self, global_conf: dict, services_conf: list[dict], *, check_changes: bool = True, changed_service: Optional[str] = None ) -> Union[str, Set[str]]: """Generates the nginx configuration file from the given configuration @@ -108,7 +108,14 @@ class Config: return self.__db.get_services_settings(methods=methods, with_drafts=with_drafts) def check_variables( - self, variables: dict, config: dict, *, global_config: bool = False, ignored_multiples: Optional[Set[str]] = None, threaded: bool = False + self, + variables: dict, + config: dict, + *, + global_config: bool = False, + ignored_multiples: Optional[Set[str]] = None, + new: bool = False, + threaded: bool = False, ) -> dict: """Testify that the variables passed are valid @@ -124,6 +131,8 @@ class Config: """ self.__data.load_from_file() plugins_settings = self.get_plugins_settings() + blacklisted_settings = get_blacklisted_settings(global_config) + for k, v in variables.copy().items(): check = False @@ -140,7 +149,7 @@ class Config: variables.pop(k) continue - if setting in get_blacklisted_settings(global_config): + if setting in blacklisted_settings: message = f"Variable {k} is not editable, ignoring it" if threaded: self.__data["TO_FLASH"].append({"content": message, "type": "error"}) @@ -151,7 +160,7 @@ class Config: elif setting not in config and plugins_settings[setting]["default"] == v: variables.pop(k) continue - elif config[setting]["method"] not in ("default", "ui"): + elif not new and setting != "IS_DRAFT" and config[setting]["method"] not in ("default", "ui"): 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"}) @@ -219,7 +228,7 @@ class Config: return f"Service {service['SERVER_NAME'].split(' ')[0]} already exists.", 1 services.append(variables | {"IS_DRAFT": "yes" if is_draft else "no"}) - ret = self.__gen_conf(self.get_config(methods=False), services, check_changes=not is_draft) + ret = self.gen_conf(self.get_config(methods=False), services, check_changes=not is_draft) if isinstance(ret, str): return ret, 1 return f"Configuration for {variables['SERVER_NAME'].split(' ')[0]} has been generated.", 0 @@ -259,7 +268,7 @@ class Config: if k.startswith(old_server_name_splitted[0]): config.pop(k) - ret = self.__gen_conf(config, services, check_changes=check_changes, changed_service=server_name_splitted[0]) + ret = self.gen_conf(config, services, check_changes=check_changes, changed_service=server_name_splitted[0]) if isinstance(ret, str): return ret, 1 return f"Configuration for {old_server_name_splitted[0]} has been edited.", 0 @@ -277,7 +286,7 @@ class Config: str the confirmation message """ - ret = self.__gen_conf(self.get_config(methods=False) | variables, self.get_services(methods=False), check_changes=check_changes) + ret = self.gen_conf(self.get_config(methods=False) | variables, self.get_services(methods=False), check_changes=check_changes) if isinstance(ret, str): return ret, 1 return "The global configuration has been edited.", 0 @@ -327,7 +336,7 @@ class Config: if k in service: service.pop(k) - ret = self.__gen_conf(new_env, new_services, check_changes=check_changes) + ret = self.gen_conf(new_env, new_services, check_changes=check_changes) if isinstance(ret, str): return ret, 1 return f"Configuration for {service_name} has been deleted.", 0 diff --git a/src/ui/app/routes/services.py b/src/ui/app/routes/services.py index 3ff352db8..a203ccdf4 100644 --- a/src/ui/app/routes/services.py +++ b/src/ui/app/routes/services.py @@ -2,38 +2,99 @@ from re import match 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 import Blueprint, Response, redirect, render_template, request, url_for from flask_login import login_required from app.dependencies import BW_CONFIG, DATA, DB -from app.routes.utils import handle_error, manage_bunkerweb, wait_applying +from app.routes.utils import handle_error, manage_bunkerweb, verify_data_in_form, wait_applying services = Blueprint("services", __name__) -@services.route("/services", methods=["GET", "POST"]) +@services.route("/services", methods=["GET"]) @login_required def services_page(): - if request.method == "POST": # TODO: Handle creation and deletion of services - if DB.readonly: - return handle_error("Database is in read-only mode", "services") + return render_template("services.html", services=DB.get_services(with_drafts=True)) - # 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) +@services.route("/services/delete", methods=["POST"]) +@login_required +def services_delete(): + verify_data_in_form( + data={"services": None}, + err_message="Missing services parameter on /services/delete.", + redirect_url="services", + next=True, + ) + services = request.form["services"].split(",") + if not services: + return handle_error("No services selected.", "services", True) + DATA.load_from_file() - # return redirect(url_for("loading", next=url_for("services.services_page"), message=message)) + def delete_services(services): + wait_applying() - return render_template("services.html") # TODO + db_config = BW_CONFIG.get_config(methods=False) + db_services = DB.get_services(with_drafts=True) + all_drafts = True + services_to_delete = set() + non_ui_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 not db_service["is_draft"]: + all_drafts = False + services_to_delete.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 deleted.", "type": "error"}) + + if not services_to_delete: + DATA["TO_FLASH"].append({"content": "All selected services could not be found or are not UI services.", "type": "error"}) + DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) + return + + db_config["SERVER_NAME"] = " ".join([service["id"] for service in db_services if service["id"] not in services_to_delete]) + new_env = db_config.copy() + + for setting in db_config: + for service in services_to_delete: + if setting.startswith(f"{service}_"): + del new_env[setting] + + ret = BW_CONFIG.gen_conf(new_env, [], check_changes=not all_drafts) + 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"Deleted services {', '.join(services_to_delete)}", "type": "success"}) + DATA["RELOADING"] = False + + DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True}) + Thread(target=delete_services, args=(services,)).start() + + return redirect( + url_for( + "loading", + next=url_for("services.services_page"), + message=f"Deleting services {', '.join(services)}", + ) + ) @services.route("/services/", methods=["GET", "POST"]) @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(" ") + services = BW_CONFIG.get_config(global_only=True, methods=False, with_drafts=True, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ") service_exists = service in services + if service != "new" and not service_exists: + return Response("Service not found", status=404) + if request.method == "POST": if DB.readonly: return handle_error("Database is in read-only mode", "services") @@ -44,54 +105,61 @@ def services_service_page(service: str): del variables["csrf_token"] mode = request.args.get("mode", "easy") - is_draft = variables.get("IS_DRAFT", "no") == "yes" + is_draft = variables.pop("IS_DRAFT", "no") == "yes" - def update_service(variables: Dict[str, str], is_draft: bool, threaded: bool = False): # TODO: handle easy and raw modes + def update_service(service: str, variables: Dict[str, str], is_draft: bool, mode: str): # TODO: handle easy and raw modes wait_applying() # Edit check fields and remove already existing ones - if service_exists: + if service != "new": config = DB.get_config(methods=True, with_drafts=True, filtered_settings=list(variables.keys()), service=service) else: - config = DB.get_config(methods=True, with_drafts=True, filtered_settings=list(variables.keys())) - was_draft = config.get(f"{service}_IS_DRAFT", {"value": "no"})["value"] == "yes" + config = DB.get_config(global_only=True, methods=True, filtered_settings=list(variables.keys())) + was_draft = config.get("IS_DRAFT", {"value": "no"})["value"] == "yes" old_server_name = variables.pop("OLD_SERVER_NAME", "") ignored_multiples = set() # 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"]: + 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, threaded=threaded) + variables = BW_CONFIG.check_variables(variables, config, ignored_multiples=ignored_multiples, new=service == "new", threaded=True) 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["TO_FLASH"].append({"content": content, "type": "warning"}) DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) return if "SERVER_NAME" not in variables: + if service == "new": + DATA["TO_FLASH"].append({"content": "The service was not created because the server name was not provided.", "type": "error"}) + DATA.update({"RELOADING": False, "CONFIG_CHANGED": False}) + return variables["SERVER_NAME"] = old_server_name + if service == "new": + old_server_name = variables["SERVER_NAME"] + manage_bunkerweb( "services", variables, old_server_name, - operation="edit" if service_exists else "new", + operation="edit" if service != "new" else "new", is_draft=is_draft, was_draft=was_draft, - threaded=threaded, + threaded=True, ) DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True}) - Thread(target=update_service, args=(variables, is_draft, True)).start() + Thread(target=update_service, args=(service, variables, is_draft, mode)).start() + + if service == "new": + service = variables["SERVER_NAME"].split(" ")[0] arguments = {} if mode != "easy": @@ -107,17 +175,32 @@ def services_service_page(service: str): 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}", + message=f"{'Saving' if service != 'new' else 'Creating'} 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") search_type = request.args.get("type", "all") + if service == "new": + clone = request.args.get("clone", "") + if clone: + db_config = DB.get_config(methods=True, with_drafts=True, service=clone) + db_config["SERVER_NAME"]["value"] = "" + return render_template( + "service_settings.html", + config=db_config, + mode=mode, + type=search_type, + ) + + db_config = DB.get_config(global_only=True, methods=True) + return render_template( + "service_settings.html", + config=db_config, + mode=mode, + type=search_type, + ) + db_config = DB.get_config(methods=True, with_drafts=True, service=service) return render_template( "service_settings.html", diff --git a/src/ui/app/static/css/main.css b/src/ui/app/static/css/main.css index 7b50ee21e..5d4bab86e 100644 --- a/src/ui/app/static/css/main.css +++ b/src/ui/app/static/css/main.css @@ -147,6 +147,10 @@ * Custom ******************************************************************************/ +:root { + --dt-row-selected: 29, 123, 167; +} + .badge-dot { padding: 0.35rem; font-size: 0.6rem; @@ -280,11 +284,18 @@ td.highlight { color: var(--bs-bw-green) !important; } +.btn-bw-green { + color: #fff; + background-color: var(--bs-bw-green); + border-color: var(--bs-bw-green); +} + .btn-outline-bw-green { color: var(--bs-bw-green); border-color: var(--bs-bw-green); } +.btn-bw-green:hover, .btn-outline-bw-green:hover { color: #fff; background-color: var(--bs-bw-green); diff --git a/src/ui/app/static/css/pages/instances.css b/src/ui/app/static/css/pages/instances.css deleted file mode 100644 index ab8e4c394..000000000 --- a/src/ui/app/static/css/pages/instances.css +++ /dev/null @@ -1,3 +0,0 @@ -:root { - --dt-row-selected: 29, 123, 167; -} diff --git a/src/ui/app/static/js/pages/instances.js b/src/ui/app/static/js/pages/instances.js index ea77a010f..81e55a63b 100644 --- a/src/ui/app/static/js/pages/instances.js +++ b/src/ui/app/static/js/pages/instances.js @@ -258,6 +258,7 @@ $(document).ready(function () { return; } actionLock = true; + $(".dt-button-background").click(); const instances = getSelectedInstances(); if (instances.length === 0) { diff --git a/src/ui/app/static/js/pages/services.js b/src/ui/app/static/js/pages/services.js new file mode 100644 index 000000000..e7a73cec7 --- /dev/null +++ b/src/ui/app/static/js/pages/services.js @@ -0,0 +1,359 @@ +$(document).ready(function () { + var toastNum = 0; + var actionLock = false; + const serviceNumber = parseInt($("#services_number").val()); + + const layout = { + topStart: {}, + bottomEnd: {}, + }; + + if (serviceNumber > 10) { + layout.topStart.pageLength = { + menu: [10, 25, 50, 100, { label: "All", value: -1 }], + }; + layout.bottomEnd.paging = true; + } + + layout.topStart.buttons = [ + { + extend: "colvis", + columns: "th:not(:first-child):not(:nth-child(2))", + text: 'Columns', + className: "btn btn-sm btn-outline-primary", + columnText: function (dt, idx, title) { + return idx + 1 + ". " + title; + }, + }, + { + extend: "colvisRestore", + text: 'Reset columns', + className: "btn btn-sm btn-outline-primary", + }, + { + extend: "collection", + text: 'Export', + className: "btn btn-sm btn-outline-primary", + buttons: [ + { + extend: "copy", + text: 'Copy current page', + exportOptions: { + modifier: { + page: "current", + }, + }, + }, + { + extend: "csv", + text: 'CSV', + bom: true, + filename: "bw_services", + exportOptions: { + modifier: { + search: "none", + }, + }, + }, + { + extend: "excel", + text: 'Excel', + filename: "bw_services", + exportOptions: { + modifier: { + search: "none", + }, + }, + }, + ], + }, + { + extend: "collection", + text: 'Actions', + className: "btn btn-sm btn-outline-primary", + buttons: [ + { + extend: "clone_service", + }, + { + extend: "convert_services", + text: 'Convert to online', + }, + { + extend: "convert_services", + text: 'Convert to draft', + }, + { + extend: "delete_services", + className: "text-danger", + }, + ], + }, + { + extend: "create_service", + }, + ]; + + $(document).on("hidden.bs.toast", ".toast", function (event) { + if (event.target.id.startsWith("feedback-toast")) { + setTimeout(() => { + $(this).remove(); + }, 100); + } + }); + + $("#modal-delete-services").on("hidden.bs.modal", function () { + $("#selected-services-delete").empty(); + $("#selected-services-input-delete").val(""); + }); + + $("#modal-convert-services").on("hidden.bs.modal", function () { + $("#selected-services-convert").empty(); + $("#selected-services-input-convert").val(""); + }); + + const getSelectedservices = () => { + const services = []; + $("tr.selected").each(function () { + services.push($(this).find("td:eq(1)").find("a").text().trim()); + }); + return services; + }; + + $.fn.dataTable.ext.buttons.create_service = { + text: 'Create new service', + className: "btn btn-sm btn-outline-bw-green", + action: function (e, dt, node, config) { + window.location.href = `${window.location.href}/new`; + }, + }; + + $.fn.dataTable.ext.buttons.clone_service = { + text: 'Clone service', + action: function (e, dt, node, config) { + if (actionLock) { + return; + } + actionLock = true; + const services = getSelectedservices(); + if (services.length === 0) { + actionLock = false; + return; + } else if (services.length > 1) { + const feedbackToast = $("#feedback-toast").clone(); // Clone the feedback toast + feedbackToast.attr("id", `feedback-toast-${toastNum++}`); // Corrected to set the ID for the failed toast + feedbackToast.removeClass("bg-primary text-white"); + feedbackToast.addClass("bg-primary text-white"); + feedbackToast.find("span").text("Clone failed"); + feedbackToast + .find("div.toast-body") + .text("Only one service can be cloned at a time."); + feedbackToast.appendTo("#feedback-toast-container"); // Ensure the toast is appended to the container + feedbackToast.toast("show"); + actionLock = false; + return; + } + + window.location.href = `${window.location.href}/new?clone=${services[0]}`; + }, + }; + + $.fn.dataTable.ext.buttons.convert_services = { + action: function (e, dt, node, config) { + if (actionLock) { + return; + } + actionLock = true; + $(".dt-button-background").click(); + const convertionType = $(node).text().trim().split(" ")[2]; + + const services = getSelectedservices(); + if (services.length === 0) { + actionLock = false; + return; + } + + const filteredServices = services.filter(function (service) { + const serviceType = $(`#type-${service.replace(/\./g, "-")}`); + return serviceType.data("value") !== convertionType; + }); + if (filteredServices.length === 0) { + const feedbackToast = $("#feedback-toast").clone(); // Clone the feedback toast + feedbackToast.attr("id", `feedback-toast-${toastNum++}`); // Corrected to set the ID for the failed toast + feedbackToast.removeClass("bg-primary text-white"); + feedbackToast.addClass("bg-primary text-white"); + feedbackToast.find("span").text("Conversion failed"); + feedbackToast + .find("div.toast-body") + .text("The selected services are already in the desired state."); + feedbackToast.appendTo("#feedback-toast-container"); // Ensure the toast is appended to the container + feedbackToast.toast("show"); + actionLock = false; + return; + } + + $("#selected-services-input-convert").val(services.join(",")); + + services.forEach((service) => { + // Create the list item using template literals + const listItem = + $(`
  • +
    +
    ${service}
    +
    +
  • `); + + // Clone the type element and append it to the list item + const typeClone = $("#type-" + service.replace(/\./g, "-")).clone(); + listItem.append(typeClone); + + // Append the list item to the list + $("#selected-services-convert").append(listItem); + }); + + const convert_modal = $("#modal-convert-services"); + convert_modal + .find(".modal-title") + .text( + `Convert services to ${ + convertionType.charAt(0).toUpperCase() + convertionType.slice(1) + }`, + ); + convert_modal + .find(".alert") + .text( + `Are you sure you want to convert the selected services to ${convertionType}?`, + ); + convert_modal + .find("button[type=submit]") + .text(`Convert to ${convertionType}`); + $("#convertion-type").val(convertionType); + const modal = new bootstrap.Modal(convert_modal); + modal.show(); + + actionLock = false; + }, + }; + + $.fn.dataTable.ext.buttons.delete_services = { + text: 'Delete', + action: function (e, dt, node, config) { + if (actionLock) { + return; + } + actionLock = true; + $(".dt-button-background").click(); + + const services = getSelectedservices(); + if (services.length === 0) { + actionLock = false; + return; + } + + $("#selected-services-input-delete").val(services.join(",")); + + const delete_modal = $("#modal-delete-services"); + services.forEach((service) => { + // Create the list item using template literals + const listItem = + $(`
  • +
    +
    ${service}
    +
    +
  • `); + + // Clone the type element and append it to the list item + const typeClone = $("#type-" + service.replace(/\./g, "-")).clone(); + listItem.append(typeClone); + + // Append the list item to the list + $("#selected-services-delete").append(listItem); + }); + + const modal = new bootstrap.Modal(delete_modal); + modal.show(); + + actionLock = false; + }, + }; + + const services_table = new DataTable("#services", { + columnDefs: [ + { + orderable: false, + render: DataTable.render.select(), + targets: 0, + }, + { + targets: "_all", // Target all columns + createdCell: function (td, cellData, rowData, row, col) { + $(td).addClass("text-center align-items-center"); // Apply 'text-center' class to + }, + }, + ], + order: [[5, "desc"]], + autoFill: false, + colReorder: true, + responsive: true, + select: { + style: "multi+shift", + selector: "td:first-child", + headerCheckbox: false, + }, + layout: layout, + language: { + info: "Showing _START_ to _END_ of _TOTAL_ services", + infoEmpty: "No services available", + infoFiltered: "(filtered from _MAX_ total services)", + lengthMenu: "Display _MENU_ services", + zeroRecords: "No matching services found", + select: { + rows: { + _: "Selected %d services", + 0: "No services selected", + 1: "Selected 1 service", + }, + }, + }, + initComplete: function (settings, json) { + $("#services_wrapper .btn-secondary").removeClass("btn-secondary"); + }, + }); + + services_table.on("mouseenter", "td", function () { + const rowIdx = services_table.cell(this).index().row; + + services_table + .cells() + .nodes() + .each((el) => el.classList.remove("highlight")); + + services_table + .cells() + .nodes() + .each(function (el) { + if (services_table.cell(el).index().row === rowIdx) + el.classList.add("highlight"); + }); + }); + + services_table.on("mouseleave", "td", function () { + services_table + .cells() + .nodes() + .each((el) => el.classList.remove("highlight")); + }); + + // Event listener for the select-all checkbox + $("#select-all-rows").on("change", function () { + const isChecked = $(this).prop("checked"); + + if (isChecked) { + // Select all rows on the current page + services_table.rows({ page: "current" }).select(); + } else { + // Deselect all rows on the current page + services_table.rows({ page: "current" }).deselect(); + } + }); +}); diff --git a/src/ui/app/static/js/plugins-settings.js b/src/ui/app/static/js/plugins-settings.js index d017fa617..4e13714db 100644 --- a/src/ui/app/static/js/plugins-settings.js +++ b/src/ui/app/static/js/plugins-settings.js @@ -1,8 +1,10 @@ $(document).ready(() => { + var toastNum = 0; let currentPlugin = "general"; - let currentMode = "easy"; - let currentType = "all"; + let currentMode = $("#selected-mode").val(); + let currentType = $("#selected-type").val(); + const $serviceMethodInput = $("#service-method"); const $pluginSearch = $("#plugin-search"); const $pluginTypeSelect = $("#plugin-type-select"); const $pluginKeywordSearch = $("#plugin-keyword-search"); @@ -107,6 +109,124 @@ $(document).ready(() => { } }; + const getFormFromSettings = () => { + const form = $("
    ", { + method: "POST", + action: window.location.href, + class: "visually-hidden", + }); + + // Helper function to append hidden inputs + const appendHiddenInput = (form, name, value) => { + form.append( + $("", { + type: "hidden", + name: name, + value: $("
    ").text(value).html(), // Sanitize the value + }), + ); + }; + + // Handle missing CSRF token gracefully + const csrfToken = $("#csrf_token").val() || ""; + appendHiddenInput(form, "csrf_token", csrfToken); + + // TODO: support easy mode + if (currentMode === undefined || currentMode === "advanced") { + $("div[id^='navs-plugins-']") + .find("input, select") + .each(function () { + const $this = $(this); + const settingName = $this.attr("name"); + const settingType = $this.attr("type"); + const originalValue = $this.data("original"); + let settingValue = $this.val(); + + if ($this.is("select")) { + settingValue = $this.val(); + } else if (settingType === "checkbox") { + settingValue = $this.is(":checked") ? "yes" : "no"; + } + + if (settingValue == originalValue) return; + + appendHiddenInput(form, settingName, settingValue); + }); + + const $draftInput = $("#is-draft"); + if ($draftInput.length) { + appendHiddenInput(form, "IS_DRAFT", $draftInput.val()); + } + } else if (currentMode === "raw") { + // Helper function to parse configuration strings into an object + const parseConfig = (selector) => { + const rawConfig = $(selector).val(); + if (!rawConfig) return {}; + return rawConfig + .trim() + .split("\n") + .reduce((acc, line) => { + const [key, value] = line.split("="); + if (key && value !== undefined) { + acc[key.trim()] = value.trim(); + } + return acc; + }, {}); + }; + + // Parse original and default configurations + const configOriginals = parseConfig("#raw-config-originals"); + const configDefaults = parseConfig("#raw-config-defaults"); + + // Sets to keep track of processed keys + const formKeys = new Set(); + const skippedKeys = new Set(); + + // Process the current configuration + const $rawConfig = $("#raw-config"); + if ($rawConfig.length) { + const configLines = $rawConfig + .val() + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); + + configLines.forEach((line) => { + const [key, value] = line.split("=").map((str) => str.trim()); + if (!key || value === undefined) { + console.warn(`Skipping malformed line: ${line}`); + return; + } + + // Skip unchanged values except for 'IS_DRAFT' + if (key !== "IS_DRAFT" && configOriginals[key] === value) { + skippedKeys.add(key); + return; + } + + appendHiddenInput(form, key, value); + formKeys.add(key); + }); + } + + // Append default values if they are not already in the form and not skipped + Object.entries(configDefaults).forEach(([key, value]) => { + if (!formKeys.has(key) && !skippedKeys.has(key)) { + appendHiddenInput(form, key, value); + formKeys.add(key); + } + }); + + // Append 'OLD_SERVER_NAME' if it exists + const $oldServerName = $("#old-server-name"); + if ($oldServerName.length) { + appendHiddenInput(form, "OLD_SERVER_NAME", $oldServerName.val()); + } + } + + return form; + }; + const debounce = (func, delay) => { let debounceTimer; return (...args) => { @@ -465,57 +585,59 @@ $(document).ready(() => { }); }); - $("#save-settings").on("click", function () { - const form = $("", { - method: "POST", - action: window.location.href, - class: "visually-hidden", - }); + $(".save-settings").on("click", function () { + const form = getFormFromSettings(); + // TODO: support easy mode + let minSettings = 4; + if (!form.find("input[name='IS_DRAFT']").length) minSettings = 2; - 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(); + const draftInput = $("#is-draft"); + const wasDraft = draftInput.data("original") === "yes"; + let isDraft = draftInput.val() === "yes"; + if (currentMode === "raw") + isDraft = form.find("input[name='IS_DRAFT']").val() === "yes"; - 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) { + if (form.children().length < minSettings && isDraft === wasDraft) { alert("No changes detected."); return; } + $(window).off("beforeunload"); form.appendTo("body").submit(); }); + $("#toggle-draft").on("click", function () { + const draftInput = $("#is-draft"); + const isDraft = draftInput.val() === "yes"; + + draftInput.val(isDraft ? "no" : "yes"); + $(this).html( + ` ${isDraft ? "Online" : "Draft"}`, + ); + }); + + $(".copy-settings").on("click", function () { + const config = $("#raw-config").val(); + + // Use the Clipboard API + navigator.clipboard + .writeText(config) + .then(() => { + // Show tooltip + const button = $(this); + button.attr("data-bs-original-title", "Copied!").tooltip("show"); + + // Hide tooltip after 2 seconds + setTimeout(() => { + button.tooltip("hide").attr("data-bs-original-title", ""); + }, 2000); + }) + .catch((err) => { + console.error("Failed to copy text: ", err); + }); + }); + $('div[id^="multiple-"]') .filter(function () { return /^multiple-.*-\d+$/.test($(this).attr("id")); @@ -602,4 +724,65 @@ $(document).ready(() => { } $pluginTypeSelect.trigger("change"); + + if (currentMode === "advanced") { + const serverNameSetting = $("#setting-general-server-name"); + if (!serverNameSetting.val()) { + if (currentType !== "all") { + currentType = "all"; + $pluginTypeSelect.val("all"); + } else + $(`button[data-bs-target="#navs-plugins-${currentPlugin}"]`).tab( + "show", + ); + + if (currentPlugin !== "general") { + $(`button[data-bs-target="#navs-plugins-general"]`).tab("show"); + } + + updateUrlParams({ type: null }); + + highlightSettings(serverNameSetting.closest(".col-12")); + serverNameSetting.focus(); + } + } + + if ($serviceMethodInput.length) { + if ($serviceMethodInput.val() === "autoconf") { + const feedbackToast = $("#feedback-toast").clone(); // Clone the feedback toast + feedbackToast.attr("id", `feedback-toast-${toastNum++}`); // Corrected to set the ID for the failed toast + feedbackToast.removeClass("bg-primary text-white"); + feedbackToast.addClass("bg-primary text-white"); + feedbackToast.find("span").text("The service method is autoconf."); + feedbackToast + .find("div.toast-body") + .html( + "

    As the service method is set to autoconf, the configuration is locked.

    Any changes made will not be saved.
    This is to prevent conflicts with the autoconf and the web UI.

    ", + ); + feedbackToast.attr("data-bs-autohide", "false"); + feedbackToast.appendTo("#feedback-toast-container"); // Ensure the toast is appended to the container + feedbackToast.toast("show"); + } + } + + $(window).on("beforeunload", function (e) { + const form = getFormFromSettings(); + // TODO: support easy mode + let minSettings = 4; + if (!form.find("input[name='IS_DRAFT']").length) minSettings = 2; + + const draftInput = $("#is-draft"); + const wasDraft = draftInput.data("original") === "yes"; + let isDraft = draftInput.val() === "yes"; + if (currentMode === "raw") + isDraft = form.find("input[name='IS_DRAFT']").val() === "yes"; + + if (form.children().length < minSettings && isDraft === wasDraft) return; + + // Cross-browser compatibility (for older browsers) + var message = + "Are you sure you want to leave? Changes you made may not be saved."; + e.returnValue = message; // Standard for most browsers + return message; // Required for some browsers + }); }); diff --git a/src/ui/app/templates/base.html b/src/ui/app/templates/base.html index a236a229d..193f80305 100644 --- a/src/ui/app/templates/base.html +++ b/src/ui/app/templates/base.html @@ -25,6 +25,12 @@ + {% if current_endpoint in ("instances", "services") %} + + + {% endif %} - {% elif current_endpoint == "instances" %} - - {% endif %} @@ -90,7 +89,7 @@ nonce="{{ script_nonce }}"> - {% if current_endpoint == "instances" %} + {% if current_endpoint in ("instances", "services") %} {% endif %} @@ -125,6 +124,9 @@ {% elif current_endpoint == "instances" %} + {% elif current_endpoint == "services" %} + {% endif %} diff --git a/src/ui/app/templates/global_config.html b/src/ui/app/templates/global_config.html index 1f6d843f6..4768ce762 100644 --- a/src/ui/app/templates/global_config.html +++ b/src/ui/app/templates/global_config.html @@ -1,6 +1,8 @@ {% extends "dashboard.html" %} {% block content %} + {% set blacklisted_settings = get_blacklisted_settings(true) %} + {% set service_method = "ui" %} {% include "models/plugins_settings.html" %} {% endblock %} diff --git a/src/ui/app/templates/models/checkbox_setting.html b/src/ui/app/templates/models/checkbox_setting.html index b2fa44d5f..83cfc0899 100644 --- a/src/ui/app/templates/models/checkbox_setting.html +++ b/src/ui/app/templates/models/checkbox_setting.html @@ -5,7 +5,7 @@ type="checkbox" role="switch" aria-labelledby="{{ setting_id_prefix }}label-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}" - data-original="{{ setting_value }}" + data-original="{% if current_endpoint != 'new' %}{{ setting_value }}{% else %}{{ setting_default }}{% endif %}" data-default="{{ setting_default }}" {% if setting_value == "yes" %}checked{% endif %} {% if disabled %}disabled{% endif %}> diff --git a/src/ui/app/templates/models/input_setting.html b/src/ui/app/templates/models/input_setting.html index 3d16ab0be..b0bfa6b16 100644 --- a/src/ui/app/templates/models/input_setting.html +++ b/src/ui/app/templates/models/input_setting.html @@ -6,7 +6,7 @@ aria-labelledby="{{ setting_id_prefix }}label-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}" pattern="{{ setting_data['regex'] }}" value="{{ setting_value }}" - data-original="{{ setting_value }}" + data-original="{% if current_endpoint != 'new' %}{{ setting_value }}{% else %}{{ setting_default }}{% endif %}" data-default="{{ setting_default }}" {% if disabled %}disabled{% endif %}>
    {% set plugin_types = { @@ -88,26 +117,35 @@ role="tabpanel" aria-labelledby="navs-plugins-{{ plugin['id'] }}-tab" data-type="{{ plugin['type'] }}"> -
    -
    -
    - {{ plugin["name"] }} - v{{ plugin["version"] }} - {{ plugin_types[plugin["type"]].get('icon', ' +
    +
    + {{ plugin["name"] }} - v{{ plugin["version"] }} - {{ plugin_types[plugin["type"]].get('icon', 'Pro plugin') |safe }} -
    -

    {{ plugin["description"] }}

    +
    +

    {{ plugin["description"] }}

    -
    +
    + +  STREAM +  More info {% if plugin["page"] %} - +  Custom page {% endif %} @@ -116,11 +154,21 @@
    {% for setting, setting_data in filtered_settings.items() if not setting_data.get('multiple', false) and setting not in blacklisted_settings and (not service_endpoint or setting_data['context'] == "multisite") %} {% set setting_config = config.get(setting, {}) %} - {% set setting_value = setting_config.get("value", setting_data["default"]) %} - {% set setting_method = setting_config.get("method", "default") %} {% set setting_default = setting_data.get("default", "") %} + {% set setting_value = setting_config.get("value", setting_default) %} + {% set setting_method = setting_config.get("method", "default") %} {% set setting_template = setting_config.get("template", "") %} {% set disabled = setting_method not in ('ui', 'default') %} + {% if current_endpoint == "new" %} + {% set disabled = false %} + {% if setting == "SERVER_NAME" %} + {% set setting_value = "" %} + {% endif %} + {% endif %} + {% if service_method == "autoconf" %} + {% set setting_method = "autoconf" %} + {% set disabled = true %} + {% endif %}
    @@ -164,7 +212,10 @@ {% include "models/checkbox_setting.html" %} {% else %} {% if setting == "SERVER_NAME" and current_endpoint != "global-config" %} - + {% endif %} {% include "models/input_setting.html" %} {% endif %} @@ -227,11 +278,15 @@
    {% for setting, setting_data in settings.items() if setting not in blacklisted_settings %} {% set setting_config = config.get(setting, {}) %} - {% set setting_value = setting_config.get("value", setting_data["default"]) %} - {% set setting_method = setting_config.get("method", "default") %} {% set setting_default = setting_data.get("default", "") %} + {% set setting_value = setting_config.get("value", setting_default) %} + {% set setting_method = setting_config.get("method", "default") %} {% set setting_template = setting_config.get("template", "") %} - {% set disabled = setting_method not in ('ui', 'default') %} + {% set disabled = current_endpoint != "new" and setting_method not in ('ui', 'default') %} + {% if service_method == "autoconf" %} + {% set setting_method = "autoconf" %} + {% set disabled = true %} + {% endif %}
    diff --git a/src/ui/app/templates/models/plugins_settings_easy.html b/src/ui/app/templates/models/plugins_settings_easy.html new file mode 100644 index 000000000..e69de29bb diff --git a/src/ui/app/templates/models/plugins_settings_raw.html b/src/ui/app/templates/models/plugins_settings_raw.html new file mode 100644 index 000000000..ca2679361 --- /dev/null +++ b/src/ui/app/templates/models/plugins_settings_raw.html @@ -0,0 +1,61 @@ + +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + {% set config_lines = ["IS_DRAFT=" + config.get('IS_DRAFT', {}).get('value', 'no')] %} + {% set default_settings = ["IS_DRAFT=no"] %} + {% for plugin in plugins %} + {% set filtered_settings = get_filtered_settings(plugin["settings"], current_endpoint == "global-config") %} + {% if filtered_settings %} + {% for setting, setting_data in filtered_settings.items() if setting not in blacklisted_settings %} + {% set setting_config = config.get(setting, {}) %} + {% set setting_default = setting_data.get("default", "") %} + {% set setting_value = setting_config.get("value", setting_default) %} + {% if setting_value != setting_default %} + {% if config_lines.append(setting + "=" + setting_value) %}{% endif %} + {% if default_settings.append(setting + "=" + setting_default) %}{% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% endfor %} + + {% set raw_config = config_lines | join('\r\n') %} + + +
    + + +
    +
    diff --git a/src/ui/app/templates/models/select_setting.html b/src/ui/app/templates/models/select_setting.html index 9ad321674..b34b9646b 100644 --- a/src/ui/app/templates/models/select_setting.html +++ b/src/ui/app/templates/models/select_setting.html @@ -3,7 +3,7 @@ name="{{ setting }}" class="form-select" aria-labelledby="{{ setting_id_prefix }}label-{{ plugin['id'] }}-{{ setting_data['id'] }}{{ setting_id_suffix }}" - data-original="{{ setting_value }}" + data-original="{% if current_endpoint != 'new' %}{{ setting_value }}{% else %}{{ setting_default }}{% endif %}" data-default="{{ setting_default }}" {% if disabled %}disabled{% endif %}> {% for option in setting_data["select"] %} diff --git a/src/ui/app/templates/service_settings.html b/src/ui/app/templates/service_settings.html index 72d5b0a3e..efd7ead9d 100644 --- a/src/ui/app/templates/service_settings.html +++ b/src/ui/app/templates/service_settings.html @@ -1,6 +1,19 @@ {% extends "dashboard.html" %} {% block content %} + {% set blacklisted_settings = get_blacklisted_settings() %} + {% set service_method = "ui" %} + {% if current_endpoint != "global-config" %} + {% set service_method = config.get("SERVER_NAME", {"method": "ui"})["method"] %} + + {% endif %} +
    - + role="tabpanel">{% include "models/plugins_settings_raw.html" %}
    +
    + {% endblock %} diff --git a/src/ui/app/templates/services.html b/src/ui/app/templates/services.html new file mode 100644 index 000000000..de92b27d2 --- /dev/null +++ b/src/ui/app/templates/services.html @@ -0,0 +1,154 @@ +{% extends "dashboard.html" %} +{% block content %} + +
    + + + + + + + + + + + + + + + {% for service in services %} + + + + + + + + + {% endfor %} + +
    + + Select All + Service nameMethodTypeCreatedLast Update
    +  {{ service["id"] }} + {{ service["method"] }} + {% if service['is_draft'] %} + Draft + {% else %} + Online + {% endif %} + {{ service['creation_date'].astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }}{{ service['last_update'].astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }}
    +
    + + + + +{% endblock %}