From aa117403bd267e1d95139f5e97e0280d5cfb70ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9ophile=20Diot?= Date: Sun, 1 Sep 2024 13:40:01 +0200 Subject: [PATCH] Finished v1 of instances page in web UI --- src/common/db/Database.py | 31 ++- src/common/utils/ApiCaller.py | 1 - src/scheduler/main.py | 4 +- src/ui/app/models/instance.py | 44 ++-- src/ui/app/routes/instances.py | 116 ++++++++--- src/ui/app/static/css/core.css | 39 +--- src/ui/app/static/css/main.css | 74 +++++++ src/ui/app/static/css/pages/instances.css | 8 +- src/ui/app/static/img/diamond-blue.svg | 43 ++++ src/ui/app/static/img/diamond.svg | 43 ++++ src/ui/app/static/js/pages/instances.js | 239 ++++++++++++++++++++-- src/ui/app/templates/dashboard.html | 23 +-- src/ui/app/templates/flash.html | 2 +- src/ui/app/templates/instances.html | 129 +++++++----- src/ui/app/templates/menu.html | 74 +------ 15 files changed, 614 insertions(+), 256 deletions(-) create mode 100644 src/ui/app/static/img/diamond-blue.svg create mode 100644 src/ui/app/static/img/diamond.svg diff --git a/src/common/db/Database.py b/src/common/db/Database.py index a96a26a07..cf04c0359 100644 --- a/src/common/db/Database.py +++ b/src/common/db/Database.py @@ -3154,6 +3154,34 @@ class Database: return "" + def delete_instances(self, hostnames: List[str], changed: Optional[bool] = True) -> str: + """Delete instances.""" + with self._db_session() as session: + if self.readonly: + return "The database is read-only, the changes will not be saved" + + db_instances = session.query(Instances).filter(Instances.hostname.in_(hostnames)).all() + + if not db_instances: + return "No instances found to delete." + + for db_instance in db_instances: + session.delete(db_instance) + + if changed: + with suppress(ProgrammingError, OperationalError): + metadata = session.query(Metadata).get(1) + if metadata is not None: + metadata.instances_changed = True + metadata.last_instances_change = datetime.now().astimezone() + + try: + session.commit() + except BaseException as e: + return f"An error occurred while deleting the instances {', '.join(hostnames)}.\n{e}" + + return "" + def delete_instance(self, hostname: str, changed: Optional[bool] = True) -> str: """Delete instance.""" with self._db_session() as session: @@ -3236,7 +3264,8 @@ class Database: return f"Instance {hostname} does not exist, will not be updated." db_instance.status = status - db_instance.last_seen = datetime.now().astimezone() + if status != "down": + db_instance.last_seen = datetime.now().astimezone() try: session.commit() diff --git a/src/common/utils/ApiCaller.py b/src/common/utils/ApiCaller.py index 3c9c78034..ba5ff2efd 100644 --- a/src/common/utils/ApiCaller.py +++ b/src/common/utils/ApiCaller.py @@ -46,7 +46,6 @@ class ApiCaller: with ThreadPoolExecutor() as executor: future_to_api = {executor.submit(send_request, api, deepcopy(files) if files else None): api for api in self.apis} for future in as_completed(future_to_api): - api = future_to_api[future] try: api, sent, err, status, resp = future.result() if not sent: diff --git a/src/scheduler/main.py b/src/scheduler/main.py index 581c60a22..858b627c0 100644 --- a/src/scheduler/main.py +++ b/src/scheduler/main.py @@ -811,13 +811,13 @@ if __name__ == "__main__": success, responses = SCHEDULER.send_to_apis("POST", "/reload", response=True) if not success: + reachable = False LOGGER.debug(f"Error while reloading all bunkerweb instances: {responses}") - reachable = False for db_instance in SCHEDULER.db.get_instances(): status = responses.get(db_instance["hostname"], {"status": "down"}).get("status", "down") if status == "success": - reachable = True + success = True ret = SCHEDULER.db.update_instance(db_instance["hostname"], "up" if status == "success" else "down") if ret: LOGGER.error(f"Couldn't update instance {db_instance['hostname']} status to down in the database: {ret}") diff --git a/src/ui/app/models/instance.py b/src/ui/app/models/instance.py index b4feef89d..aa823ece2 100644 --- a/src/ui/app/models/instance.py +++ b/src/ui/app/models/instance.py @@ -69,72 +69,72 @@ class Instance: try: result = self.apiCaller.send_to_apis("POST", "/reload")[0] except BaseException as e: - return f"Can't reload {self.hostname}: {e}" + return f"Can't reload instance {self.hostname}: {e}" if result: return f"Instance {self.hostname} has been reloaded." - return f"Can't reload {self.hostname}" + return f"Can't reload instance {self.hostname}" def start(self) -> str: raise NotImplementedError("Method not implemented yet") try: result = self.apiCaller.send_to_apis("POST", "/start")[0] except BaseException as e: - return f"Can't start {self.hostname}: {e}" + return f"Can't start instance {self.hostname}: {e}" if result: return f"Instance {self.hostname} has been started." - return f"Can't start {self.hostname}" + return f"Can't start instance {self.hostname}" def stop(self) -> str: try: result = self.apiCaller.send_to_apis("POST", "/stop")[0] except BaseException as e: - return f"Can't stop {self.hostname}: {e}" + return f"Can't stop instance {self.hostname}: {e}" if result: return f"Instance {self.hostname} has been stopped." - return f"Can't stop {self.hostname}" + return f"Can't stop instance {self.hostname}" def restart(self) -> str: try: result = self.apiCaller.send_to_apis("POST", "/restart")[0] except BaseException as e: - return f"Can't restart {self.hostname}: {e}" + return f"Can't restart instance {self.hostname}: {e}" if result: return f"Instance {self.hostname} has been restarted." - return f"Can't restart {self.hostname}" + return f"Can't restart instance {self.hostname}" def ban(self, ip: str, exp: float, reason: str) -> str: try: result = self.apiCaller.send_to_apis("POST", "/ban", data={"ip": ip, "exp": exp, "reason": reason})[0] except BaseException as e: - return f"Can't ban {ip} on {self.hostname}: {e}" + return f"Can't ban {ip} on instance {self.hostname}: {e}" if result: - return f"IP {ip} has been banned on {self.hostname} for {exp} seconds{f' with reason: {reason}' if reason else ''}." - return f"Can't ban {ip} on {self.hostname}" + return f"IP {ip} has been banned on instance {self.hostname} for {exp} seconds{f' with reason: {reason}' if reason else ''}." + return f"Can't ban {ip} on instance {self.hostname}" def unban(self, ip: str) -> str: try: result = self.apiCaller.send_to_apis("POST", "/unban", data={"ip": ip})[0] except BaseException as e: - return f"Can't unban {ip} on {self.hostname}: {e}" + return f"Can't unban {ip} on instance {self.hostname}: {e}" if result: - return f"IP {ip} has been unbanned on {self.hostname}." - return f"Can't unban {ip} on {self.hostname}" + return f"IP {ip} has been unbanned on instance {self.hostname}." + return f"Can't unban {ip} on instance {self.hostname}" def bans(self) -> Tuple[str, dict[str, Any]]: try: result = self.apiCaller.send_to_apis("GET", "/bans", response=True) except BaseException as e: - return f"Can't get bans from {self.hostname}: {e}", result[1] + return f"Can't get bans from instance {self.hostname}: {e}", result[1] if result[0]: return "", result[1] - return f"Can't get bans from {self.hostname}", result[1] + return f"Can't get bans from instance {self.hostname}", result[1] def reports(self) -> Tuple[bool, dict[str, Any]]: return self.apiCaller.send_to_apis("GET", "/metrics/requests", response=True) @@ -145,16 +145,16 @@ class Instance: def metrics_redis(self) -> Tuple[bool, dict[str, Any]]: return self.apiCaller.send_to_apis("GET", "/redis/stats", response=True) - def ping(self, plugin_id: Optional[str] = None) -> Tuple[bool, dict[str, Any]]: + def ping(self, plugin_id: Optional[str] = None) -> Tuple[Union[bool, str], dict[str, Any]]: if not plugin_id: try: - result = self.apiCaller.send_to_apis("GET", "/ping")[0] + result = self.apiCaller.send_to_apis("GET", "/ping") except BaseException as e: - return f"Can't ping {self.hostname}: {e}", {} + return f"Can't ping instance {self.hostname}: {e}", {} - if result: - return f"Instance {self.hostname} is up", {} - return f"Can't ping {self.hostname}", {} + if result[0]: + return f"Instance {self.hostname} is up", result[1] + return f"Can't ping instance {self.hostname}", result[1] return self.apiCaller.send_to_apis("POST", f"/{plugin_id}/ping", response=True) def data(self, plugin_endpoint) -> Tuple[bool, dict[str, Any]]: diff --git a/src/ui/app/routes/instances.py b/src/ui/app/routes/instances.py index d9f304769..89aad647a 100644 --- a/src/ui/app/routes/instances.py +++ b/src/ui/app/routes/instances.py @@ -1,16 +1,24 @@ +from concurrent.futures import ThreadPoolExecutor, as_completed from threading import Thread from time import time from typing import Literal -from flask import Blueprint, redirect, render_template, request, url_for +from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for from flask_login import login_required from app.dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DATA, DB -from app.routes.utils import handle_error, manage_bunkerweb, verify_data_in_form +from app.models.instance import Instance +from app.routes.utils import handle_error, verify_data_in_form instances = Blueprint("instances", __name__) +ACTIONS = { + "reload": {"present": "Reloading", "past": "Reloaded"}, + "stop": {"present": "Stopping", "past": "Stopped"}, + "delete": {"present": "Deleting", "past": "Deleted"}, +} + @instances.route("/instances", methods=["GET"]) @login_required @@ -55,40 +63,94 @@ def instances_new(): return redirect(url_for("loading", next=url_for("instances.instances_page"), message=f"Creating new instance {instance['hostname']}")) -@instances.route("/instances//", methods=["POST"]) +@instances.route("/instances/", methods=["POST"]) @login_required -def instances_action(instance_hostname: str, action: Literal["ping", "reload", "stop", "delete"]): # TODO: see if we can support start and restart - if action == "delete": - delete_instance = None - for instance in BW_INSTANCES_UTILS.get_instances(): - if instance.hostname == instance_hostname: - delete_instance = instance - break +def instances_action(action: Literal["ping", "reload", "stop", "delete"]): # TODO: see if we can support start and restart + verify_data_in_form( + data={"instances": None}, + err_message=f"Missing instances parameter on /instances/{action}.", + redirect_url="instances", + next=True, + ) + instances = request.form["instances"].split(",") + if not instances: + return handle_error("No instances selected.", "instances", True) - if not delete_instance: - return handle_error(f"Instance {instance_hostname} not found.", "instances", True) - if delete_instance.method != "ui": - return handle_error(f"Instance {instance_hostname} is not a UI instance.", "instances", True) + if action == "ping": + succeed = [] + failed = [] - ret = DB.delete_instance(instance_hostname) + def ping_instance(instance): + ret = Instance.from_hostname(instance, DB) + if not ret: + return {"hostname": instance, "message": f"The instance {instance} does not exist."} + ret = ret.ping() + if ret[0].startswith("Can't"): + return {"hostname": instance, "message": ret[0]} + return instance + + with ThreadPoolExecutor() as executor: + future_to_instance = {executor.submit(ping_instance, instance): instance for instance in instances} + for future in as_completed(future_to_instance): + instance = future.result() + if isinstance(instance, dict): + failed.append(instance) + continue + succeed.append(instance) + + return jsonify({"succeed": succeed, "failed": failed}), 200 + elif action == "delete": + delete_instances = set() + non_ui_instances = set() + for instance in DB.get_instances(): + if instance["hostname"] in instances: + if instance["method"] != "ui": + non_ui_instances.add(instance["hostname"]) + continue + delete_instances.add(instance["hostname"]) + + for non_ui_instance in non_ui_instances: + flash(f"Instance {non_ui_instance} is not a UI instance and will not be deleted.", "error") + + if not delete_instances: + return handle_error("All selected instances could not be found or are not UI instances.", "instances", True) + + ret = DB.delete_instances(delete_instances) if ret: - return handle_error(f"Couldn't delete the instance in the database: {ret}", "instances", True) + return handle_error(f"Couldn't delete the instances in the database: {ret}", "instances", True) + flash(f"Instances {', '.join(delete_instances)} deleted successfully.", "success") else: - DATA["RELOADING"] = True - DATA["LAST_RELOAD"] = time() - Thread( - target=manage_bunkerweb, - args=("instances", instance_hostname), - kwargs={"operation": action, "threaded": True}, - ).start() + + def execute_action(instance): + ret = Instance.from_hostname(instance, DB) + if not ret: + DATA["TO_FLASH"].append({"content": f"The instance {instance} does not exist.", "type": "error"}) + return + + method = getattr(ret, action, None) + if method is None or not callable(method): + DATA["TO_FLASH"].append({"content": f"The instance {instance} does not have a {action} method.", "type": "error"}) + return + + ret = method() + if ret.startswith("Can't"): + DATA["TO_FLASH"].append({"content": ret, "type": "error"}) + return + DATA["TO_FLASH"].append({"content": f"Instance {instance} {ACTIONS[action]['past']} successfully.", "type": "success"}) + + def execute_actions(instances): + DATA["RELOADING"] = True + DATA["LAST_RELOAD"] = time() + with ThreadPoolExecutor() as executor: + executor.map(execute_action, instances) + DATA["RELOADING"] = False + + Thread(target=execute_actions, args=(instances,)).start() return redirect( url_for( "loading", next=url_for("instances.instances_page"), - message=( - (f"{action.title()}ing" if action not in ("delete", "stop") else ("Deleting" if action == "delete" else "Stopping")) - + f" instance {instance_hostname}" - ), + message=(f"{ACTIONS[action]['present']} instances {', '.join(instances)}"), ) ) diff --git a/src/ui/app/static/css/core.css b/src/ui/app/static/css/core.css index 9728e45d5..5102640d2 100644 --- a/src/ui/app/static/css/core.css +++ b/src/ui/app/static/css/core.css @@ -22580,29 +22580,18 @@ body { } } -@keyframes backgroundColorPhase { - 0% { - background-color: var(--bs-primary); /* Start with primary color */ - } - 50% { - background-color: var(--bs-bw-green); /* Transition to secondary color */ - } - 100% { - background-color: var(--bs-primary); /* Back to primary color */ - } -} - .buy-now .btn-buy-now { position: fixed; bottom: 3rem; right: 1.625rem; z-index: 1080; - background-color: var(--bs-primary); /* Initial background color */ box-shadow: 0 1px 20px 1px var(--bs-primary); /* Initial shadow */ - border-color: var(--bs-primary); /* Initial border color */ + background-color: var(--bs-primary); /* Initial background color */ color: #fff; animation: colorPhase 3s infinite; /* Apply the color phasing animation */ - transition: box-shadow 0.3s ease-in-out; /* Smooth transition for box-shadow */ + transition: + background-color 0.3s ease-in-out, + box-shadow 0.3s ease-in-out; /* Smooth transitions */ } .buy-now .btn-buy-now:hover { @@ -22613,26 +22602,6 @@ body { border-color: var( --bs-bw-green ) !important; /* Keep the primary color on hover */ - animation: none; /* Pause the color phase animation on hover */ - animation: backgroundColorPhase 3s infinite; /* Apply the color phasing animation */ -} - -.buy-now .btn-buy-now { - position: fixed; - bottom: 3rem; - right: 1.625rem; - z-index: 1080; - box-shadow: 0 1px 20px 1px var(--bs-primary); /* Initial shadow */ - background-color: var(--bs-primary); /* Initial background color */ - color: #fff; - transition: - background-color 0.3s ease-in-out, - box-shadow 0.3s ease-in-out; /* Smooth transitions */ -} - -.buy-now .btn-buy-now:hover { - box-shadow: none; /* Remove shadow on hover */ - animation: colorPhase 3s infinite; /* Start the color phase animation on hover */ } .ui-square, diff --git a/src/ui/app/static/css/main.css b/src/ui/app/static/css/main.css index 939fd57ec..12f22cd2f 100644 --- a/src/ui/app/static/css/main.css +++ b/src/ui/app/static/css/main.css @@ -192,3 +192,77 @@ .bg-bw-green { background-color: #2eac68; } + +@media (max-width: 768px) { + .btn-responsive { + padding: 4px 9px; + font-size: 80%; + line-height: 1; + } +} + +@media (min-width: 769px) and (max-width: 992px) { + .btn-responsive { + padding: 8px 18px; + font-size: 90%; + line-height: 1.2; + } +} + +.pro-icon { + position: relative; + width: 18px; + height: 15.5px; + overflow: hidden; +} + +.pro-icon::before, +.pro-icon::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: cover; + background-position: center; + transition: opacity 1.5s ease-in-out; +} + +.pro-icon::before { + background-image: url("../img/diamond.svg"); + opacity: 1; + animation: fadeOut 1.5s infinite alternate; +} + +.pro-icon::after { + background-image: url("../img/diamond-blue.svg"); + opacity: 0; + animation: fadeIn 1.5s infinite alternate; +} + +@keyframes fadeOut { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.min-vh-70 { + min-height: 70vh !important; +} + +td.highlight { + background-color: rgba(var(--bs-primary-rgb), 0.1) !important; +} diff --git a/src/ui/app/static/css/pages/instances.css b/src/ui/app/static/css/pages/instances.css index 6892cc5c9..393efd218 100644 --- a/src/ui/app/static/css/pages/instances.css +++ b/src/ui/app/static/css/pages/instances.css @@ -1,3 +1,7 @@ -td.highlight { - background-color: rgba(var(--bs-primary-rgb), 0.1) !important; +:root { + --dt-row-selected: 29, 123, 167; +} + +#loadingModal .modal-content { + background-color: rgba(0, 0, 0, 0.75); } diff --git a/src/ui/app/static/img/diamond-blue.svg b/src/ui/app/static/img/diamond-blue.svg new file mode 100644 index 000000000..946c7f0ed --- /dev/null +++ b/src/ui/app/static/img/diamond-blue.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/app/static/img/diamond.svg b/src/ui/app/static/img/diamond.svg new file mode 100644 index 000000000..0b6eb3ee7 --- /dev/null +++ b/src/ui/app/static/img/diamond.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + \ 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 8cfba26f9..0cc35db51 100644 --- a/src/ui/app/static/js/pages/instances.js +++ b/src/ui/app/static/js/pages/instances.js @@ -1,19 +1,205 @@ $(document).ready(function () { + const getSelectedInstances = (action) => { + const instances = []; + $("tr.selected").each(function () { + instances.push($(this).find("td:first").text()); + }); + return instances; + }; + var toastNum = 0; + var actionLock = false; + $.fn.dataTable.ext.buttons.create_instance = { text: "Create new instance", className: "btn btn-sm btn-outline-primary", action: function (e, dt, node, config) { - var modal = new bootstrap.Modal($("#modal-create-instance")); + const modal = new bootstrap.Modal($("#modal-create-instance")); modal.show(); + + $("#modal-create-instance").on("shown.bs.modal", function () { + $(this).find("#hostname").focus(); + }); + }, + }; + + $.fn.dataTable.ext.buttons.ping_instances = { + text: 'Ping', + className: "btn btn-sm btn-outline-primary", + action: function (e, dt, node, config) { + if (actionLock) { + return; + } + actionLock = true; + + const instances = getSelectedInstances("ping"); + if (instances.length === 0) { + actionLock = false; + return; + } + + setTimeout(() => { + if (actionLock) { + $("#loadingModal").modal("show"); + } + }, 500); + + // Create a FormData object + const formData = new FormData(); + formData.append("csrf_token", $("#csrf_token").val()); // Add the CSRF token + formData.append("instances", instances.join(",")); // Add the instances + + // Send the form data using $.ajax + $.ajax({ + url: `${window.location.pathname}/ping`, + type: "POST", + data: formData, + processData: false, + contentType: false, + success: function (data) { + data.failed.forEach((instance) => { + var feedbackToastFailed = $("#feedback-toast").clone(); // Clone the feedback toast + feedbackToastFailed.attr("id", `feedback-toast-${toastNum++}`); // Corrected to set the ID for the failed toast + feedbackToastFailed.removeClass("bg-primary text-white"); + feedbackToastFailed.addClass("bg-danger text-white"); + feedbackToastFailed.find("span").text("Ping failed"); + feedbackToastFailed.find("div.toast-body").text(instance.message); + feedbackToastFailed.appendTo("#feedback-toast-container"); // Ensure the toast is appended to the container + feedbackToastFailed.toast("show"); + }); + + if (data.succeed.length > 0) { + var feedbackToastSucceed = $("#feedback-toast").clone(); // Clone the feedback toast + feedbackToastSucceed.attr("id", `feedback-toast-${toastNum++}`); + feedbackToastSucceed.addClass("bg-primary text-white"); + feedbackToastSucceed.find("span").text("Ping successful"); + feedbackToastSucceed + .find("div.toast-body") + .text(`Instances: ${data.succeed.join(", ")}`); + feedbackToastSucceed.appendTo("#feedback-toast-container"); + feedbackToastSucceed.toast("show"); + } + }, + error: function (xhr, status, error) { + console.error("AJAX request failed:", status, error); + alert("An error occurred while pinging the instances."); + }, + complete: function () { + actionLock = false; + $("#loadingModal").modal("hide"); + }, + }); + }, + }; + + $.fn.dataTable.ext.buttons.exec_form = { + action: function (e, dt, node, config) { + if (actionLock) { + return; + } + actionLock = true; + + const instances = getSelectedInstances("ping"); + if (instances.length === 0) { + actionLock = false; + return; + } + + const bxIcon = node.find("span.bx").attr("class").split(" ")[2]; + if (bxIcon === "bx-refresh") { + var action = "reload"; + } else if (bxIcon === "bx-stop") { + var action = "stop"; + } else { + actionLock = false; + return; + } + + // Create a form element using jQuery and set its attributes + const $form = $("
", { + method: "POST", + action: `${window.location.pathname}/${action}`, + class: "visually-hidden", + }); + + // Add CSRF token and instances as hidden inputs + $form.append( + $("", { + type: "hidden", + name: "csrf_token", + value: $("#csrf_token").val(), + }), + ); + $form.append( + $("", { + type: "hidden", + name: "instances", + value: instances.join(","), + }), + ); + + // Append the form to the body and submit it + $form.appendTo("body").submit(); + }, + }; + + $.fn.dataTable.ext.buttons.delete_instances = { + text: 'Delete', + className: "btn btn-sm btn-outline-danger", + action: function (e, dt, node, config) { + if (actionLock) { + return; + } + actionLock = true; + + const instances = getSelectedInstances("ping"); + if (instances.length === 0) { + actionLock = false; + return; + } + + $("#selected-instances-input").val(instances.join(",")); + + const delete_modal = $("#modal-delete-instances"); + instances.forEach((instance) => { + // Create the list item using template literals + const listItem = + $(`
  • +
    +
    ${instance}
    +
    +
  • `); + + // Clone the status element and append it to the list item + const statusClone = $("#status-" + instance).clone(); + listItem.append(statusClone); + + // Append the list item to the list + $("#selected-instances").append(listItem); + }); + + const modal = new bootstrap.Modal(delete_modal); + modal.show(); + + actionLock = false; }, }; const instances_table = new DataTable("#instances", { - columnDefs: [{ orderable: false, targets: 7 }], + columnDefs: [ + { + targets: "_all", // Target all columns + createdCell: function (td, cellData, rowData, row, col) { + $(td).addClass("text-center"); // Apply 'text-center' class to + }, + }, + ], order: [[6, "desc"]], autoFill: false, colReorder: true, responsive: true, + select: { + style: "multi+shift", + }, layout: { topStart: { pageLength: { @@ -22,7 +208,7 @@ $(document).ready(function () { buttons: [ { extend: "colvis", - columns: "th:not(:first-child):not(:last-child)", + columns: "th:not(:first-child)", text: "Columns", className: "btn btn-sm btn-outline-primary", columnText: function (dt, idx, title) { @@ -74,6 +260,27 @@ $(document).ready(function () { }, ], }, + bottomEnd: { + buttons: [ + { + extend: "ping_instances", + }, + { + extend: "exec_form", + text: 'Reload', + className: "btn btn-sm btn-outline-primary", + }, + { + extend: "exec_form", + text: 'Stop', + className: "btn btn-sm btn-outline-primary", + }, + { + extend: "delete_instances", + }, + ], + paging: true, + }, }, }); @@ -91,24 +298,16 @@ $(document).ready(function () { .each((el) => el.classList.add("highlight")); }); - $(document).on("click", "button[data-action]", function () { - const form = $(this).closest("form"); - const action = $(this).data("action"); // Get the action from the button - const actionSplit = form.attr("action").split("/"); - const instanceHostname = actionSplit[actionSplit.length - 1]; - - if ( - action === "delete" && - $(`#method-${instanceHostname}`).text() !== "ui" - ) { - return; - } else if ($(`#status-${instanceHostname}`).text() !== "Up") { - return; + $(document).on("hidden.bs.toast", ".toast", function (event) { + if (event.target.id.startsWith("feedback-toast")) { + setTimeout(() => { + $(this).remove(); + }, 100); } + }); - form.attr("action", `${form.attr("action")}/${action}`); - - // Now, submit the form with the updated action - form.off("submit").submit(); + $("#modal-delete-instances").on("hidden.bs.modal", function () { + $("#selected-instances").empty(); + $("#selected-instances-input").val(""); }); }); diff --git a/src/ui/app/templates/dashboard.html b/src/ui/app/templates/dashboard.html index 193a3eb88..8bed76e63 100644 --- a/src/ui/app/templates/dashboard.html +++ b/src/ui/app/templates/dashboard.html @@ -44,33 +44,14 @@ {% include "sidebar.html" %} {% if not is_pro_version %} diff --git a/src/ui/app/templates/flash.html b/src/ui/app/templates/flash.html index 7fe9403e9..f14e0f3fd 100644 --- a/src/ui/app/templates/flash.html +++ b/src/ui/app/templates/flash.html @@ -1,4 +1,4 @@ -
    +
    {% with messages = get_flashed_messages(with_categories=true) %} {% if pro_overlapped %} diff --git a/src/ui/app/templates/instances.html b/src/ui/app/templates/instances.html index e9aa41be6..8de8bb6e4 100644 --- a/src/ui/app/templates/instances.html +++ b/src/ui/app/templates/instances.html @@ -1,7 +1,27 @@ {% extends "dashboard.html" %} {% block content %} -
    + +
    + @@ -12,7 +32,6 @@ - @@ -36,62 +55,28 @@ - {% endfor %}
    Type Creation date Last seenActions
    {{ instance.type }} {{ instance.creation_date.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }} {{ instance.last_seen.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }} - - - {% if instance.status != "up" %} - {% set disabled = "disabled" %} - {% else %} - {% set disabled = "" %} - {% endif %} - {% if instance.method == "ui" %} - {% set can_delete = "" %} - {% else %} - {% set can_delete = "disabled" %} - {% endif %} -
    - - - - -
    - -
    + +
    + +
    +
    + +