Finished v1 of instances page in web UI

This commit is contained in:
Théophile Diot 2024-09-01 13:40:01 +02:00
parent bff3567a8f
commit aa117403bd
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
15 changed files with 614 additions and 256 deletions

View file

@ -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()

View file

@ -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:

View file

@ -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}")

View file

@ -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]]:

View file

@ -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/<string:instance_hostname>/<string:action>", methods=["POST"])
@instances.route("/instances/<string:action>", 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)}"),
)
)

View file

@ -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,

View file

@ -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;
}

View file

@ -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);
}

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.56 39.31">
<defs>
<style>
.cls-1 {
fill: #5c8ea4;
}
.cls-2 {
fill: #0b5577;
}
.cls-3 {
fill: #aec6d2;
}
.cls-4 {
fill: #083a4c;
}
.cls-5 {
fill: #09435a;
}
.cls-6 {
fill: #0a4c69;
}
</style>
</defs>
<g id="Calque_2-2" data-name="Calque_2">
<g id="Calque_1-2">
<g>
<polygon class="cls-1" points="40.37 5.19 31.4 10.38 14.11 10.38 5.19 5.19 13.91 0 31.65 0 40.37 5.19"/>
<polygon class="cls-4" points="45.56 12.85 34.87 19.45 31.4 10.38 40.37 5.19 45.56 12.85"/>
<polygon class="cls-5" points="45.56 12.85 22.73 39.31 34.87 19.45 45.56 12.85"/>
<polygon class="cls-2" points="14.11 10.38 10.33 19.45 0 12.85 5.19 5.19 14.11 10.38"/>
<polygon class="cls-6" points="22.73 39.31 0 12.85 10.33 19.45 22.73 39.31"/>
<polygon class="cls-1" points="34.87 19.45 22.73 39.31 10.33 19.45 34.87 19.45"/>
<polygon class="cls-3" points="34.87 19.45 10.33 19.45 14.11 10.38 31.4 10.38 34.87 19.45"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.56 39.31">
<defs>
<style>
.cls-1 {
fill: #237f4c;
}
.cls-2 {
fill: #65b278;
}
.cls-3 {
fill: #227848;
}
.cls-4 {
fill: #349f53;
}
.cls-5 {
fill: #194b34;
}
.cls-6 {
fill: #249c59;
}
</style>
</defs>
<g id="Calque_2-2" data-name="Calque_2">
<g id="Calque_1-2">
<g>
<polygon class="cls-4" points="40.37 5.19 31.4 10.38 14.11 10.38 5.19 5.19 13.91 0 31.65 0 40.37 5.19"/>
<polygon class="cls-3" points="45.56 12.85 34.87 19.45 31.4 10.38 40.37 5.19 45.56 12.85"/>
<polygon class="cls-5" points="45.56 12.85 22.73 39.31 34.87 19.45 45.56 12.85"/>
<polygon class="cls-6" points="14.11 10.38 10.33 19.45 0 12.85 5.19 5.19 14.11 10.38"/>
<polygon class="cls-1" points="22.73 39.31 0 12.85 10.33 19.45 22.73 39.31"/>
<polygon class="cls-4" points="34.87 19.45 22.73 39.31 10.33 19.45 34.87 19.45"/>
<polygon class="cls-2" points="34.87 19.45 10.33 19.45 14.11 10.38 31.4 10.38 34.87 19.45"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -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: '<span class="tf-icons bx bx-bell bx-18px me-md-2"></span><span class="d-none d-md-inline">Ping</span>',
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 = $("<form>", {
method: "POST",
action: `${window.location.pathname}/${action}`,
class: "visually-hidden",
});
// Add CSRF token and instances as hidden inputs
$form.append(
$("<input>", {
type: "hidden",
name: "csrf_token",
value: $("#csrf_token").val(),
}),
);
$form.append(
$("<input>", {
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: '<span class="tf-icons bx bx-trash bx-18px me-md-2"></span><span class="d-none d-md-inline">Delete</span>',
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 =
$(`<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="ms-2 me-auto">
<div class="fw-bold">${instance}</div>
</div>
</li>`);
// 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 <td>
},
},
],
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: '<span class="tf-icons bx bx-refresh bx-18px me-md-2"></span><span class="d-none d-md-inline">Reload</span>',
className: "btn btn-sm btn-outline-primary",
},
{
extend: "exec_form",
text: '<span class="tf-icons bx bx-stop bx-18px me-md-2"></span><span class="d-none d-md-inline">Stop</span>',
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("");
});
});

View file

@ -44,33 +44,14 @@
{% include "sidebar.html" %}
{% if not is_pro_version %}
<div class="buy-now">
<a class="btn btn-success btn-buy-now"
<a class="btn btn-responsive btn-buy-now"
role="button"
aria-pressed="true"
href="https://panel.bunkerweb.io/order/bunkerweb-pro/?utm_campaign=self&utm_source=ui"
target="_blank"
rel="noopener">
<span class="tf-icons me-2 d-flex h-100 justify-content-center align-items-center">
<svg width="19"
height="15"
viewBox="0 0 19 15"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_226_466)">
<path d="M16.8356 1.98041L13.0948 3.96082H5.88436L2.16443 1.98041L5.80095 0H13.1991L16.8356 1.98041Z" fill="#349F53" />
<path d="M19 4.90339L14.542 7.42183L13.0948 3.96088L16.8356 1.98047L19 4.90339Z" fill="#227848" />
<path d="M19 4.90332L9.47913 15L14.5419 7.42176L19 4.90332Z" fill="#194B34" />
<path d="M5.88433 3.96088L4.30795 7.42183L0 4.90339L2.1644 1.98047L5.88433 3.96088Z" fill="#249C59" />
<path d="M9.47915 15L0 4.90332L4.30795 7.42176L9.47915 15Z" fill="#237F4C" />
<path d="M14.542 7.42175L9.47919 15L4.30798 7.42175H14.542Z" fill="#349F53" />
<path d="M14.542 7.42177H4.30798L5.88437 3.96082H13.0949L14.542 7.42177Z" fill="#65B278" />
</g>
<defs>
<clipPath id="clip0_226_466">
<rect width="19" height="15" fill="white" />
</clipPath>
</defs>
</svg>
<div class="pro-icon"></div>
</span>
Upgrade to Pro</a>
</div>

View file

@ -1,4 +1,4 @@
<div class="toast-container position-fixed bottom-0 end-0 mb-3 me-3">
<div id="feedback-toast-container" class="toast-container position-fixed bottom-0 end-0 mb-3 me-3">
<!-- prettier-ignore -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if pro_overlapped %}

View file

@ -1,7 +1,27 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 h-70">
<div class="modal fade"
id="loadingModal"
data-bs-backdrop="static"
tabindex="-1"
aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content bg-transparent border-0 shadow-none">
<div class="modal-body text-center">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-3 text-white">Pinging instances, please wait...</p>
</div>
</div>
</div>
</div>
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<table id="instances" class="table w-100">
<thead>
<tr>
@ -12,7 +32,6 @@
<th>Type</th>
<th>Creation date</th>
<th>Last seen</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@ -36,62 +55,28 @@
<td>{{ instance.type }}</td>
<td>{{ instance.creation_date.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }}</td>
<td>{{ instance.last_seen.astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }}</td>
<td>
<form id="instance-form"
action="{{ url_for("instances") }}/{{ instance.hostname }}"
method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
{% if instance.status != "up" %}
{% set disabled = "disabled" %}
{% else %}
{% set disabled = "" %}
{% endif %}
{% if instance.method == "ui" %}
{% set can_delete = "" %}
{% else %}
{% set can_delete = "disabled" %}
{% endif %}
<div class="btn-group" role="group" aria-label="Action group">
<button type="button"
data-action="ping"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-original-title="Ping"
class="btn btn-sm btn-outline-primary {{ disabled }}">
<i class="tf-icons bx bx-xs bx-bell"></i>
</button>
<button type="button"
data-action="stop"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-original-title="Stop"
class="btn btn-sm btn-outline-primary {{ disabled }}">
<i class="tf-icons bx bx-xs bx-stop"></i>
</button>
<button type="button"
data-action="reload"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-original-title="Reload"
class="btn btn-sm btn-outline-primary {{ disabled }}">
<i class="tf-icons bx bx-xs bx-refresh"></i>
</button>
<button type="button"
data-action="delete"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-original-title="Delete"
class="btn btn-sm btn-outline-danger {{ can_delete }}">
<i class="tf-icons bx bx-xs bx-trash"></i>
</button>
</div>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div id="feedback-toast"
class="bs-toast toast fade"
role="alert"
aria-live="assertive"
aria-atomic="true"
data-bs-autohide="true">
<div class="toast-header">
<i class="d-block w-px-20 h-auto rounded me-2 tf-icons bx bx-bell"></i>
<span class="fw-medium me-auto">BunkerWeb Forever</span>
<small class="text-body-secondary">just now</small>
<button type="button"
class="btn-close"
data-bs-dismiss="toast"
aria-label="Close"></button>
</div>
<div class="toast-body">If you read this, ck-t means that you're curious 👀</div>
</div>
<div class="modal fade"
id="modal-create-instance"
data-bs-backdrop="static"
@ -130,10 +115,46 @@
maxlength="256"
required />
</div>
<div class="alert alert-primary text-center" role="alert">You don't need to provide the port or the server_name as the values of both <code>API_HTTP_PORT</code> and <code>API_SERVER_NAME</code> will be used for the instance configuration.</div>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-primary me-2">Create Instance</button>
<button type="reset" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="reset"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">Close</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal fade"
id="modal-delete-instances"
data-bs-backdrop="static"
tabindex="-1"
aria-hidden="true"
role="dialog">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete instances</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<form action="{{ url_for("instances") }}/delete" method="POST">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" id="selected-instances-input" name="instances" value="" />
<div class="alert alert-danger text-center" role="alert">Are you sure you want to delete the selected instances?</div>
<ul id="selected-instances" class="list-group mb-3">
</ul>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-danger me-2">Delete instances</button>
<button type="reset"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">Close</button>
</div>
</form>
</div>

View file

@ -4,55 +4,8 @@
<a href="https://www.bunkerweb.io/?utm_campaign=self&utm_source=ui"
target="_blank"
class="app-brand-link">
<span class="app-brand-logo main">
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 272.68 76.37"
xml:space="preserve">
<style type="text/css" nonce="{{ style_nonce }}">
.st0 {
fill: #ffffff;
}
.st1 {
fill: #2eac68;
}
.st2 {
fill: #0b5577;
}
</style>
<g>
<path class="st2" d="M244.87,44.46c0.16-1.27,1.3-2.12,2.59-1.95c1.01,0.13,1.85,0.96,1.92,2.05c0.14,1.52,0.26,2.71,0.36,3.85 h16.02c-0.24-1.79-0.49-3.61-0.82-6c-0.1-0.8,0.25-1.61,0.9-2.02c3.4-2.43,5.46-6.75,6.49-12.5c2.34-13.05-7.24-18.86-18.92-20.2 c-7.49-1-11.24-1.45-18.75-2.29c-1.35-0.15-2.51,0.72-2.65,2c-1.48,13.67-2.97,27.34-4.45,41.01h16.79 C244.51,47.22,244.66,46.05,244.87,44.46z M243.31,21.88c2.52,0.56,3.78,0.85,6.29,1.43c0.87-0.8,2.08-1.22,3.37-1.03 c2.21,0.32,3.75,2.31,3.43,4.42c-0.32,2.11-2.36,3.55-4.55,3.23c-1.27-0.19-2.31-0.93-2.91-1.94c-2.56-0.14-3.84-0.2-6.41-0.32 C242.84,25.36,242.99,24.2,243.31,21.88z" />
<g>
<path class="st2" d="M224.95,41c0.11-1.06-0.73-2.08-1.81-2.19c-6.15-0.63-9.23-0.91-15.38-1.43c-1.16-0.1-2.02-1.09-1.93-2.23 c0.01-0.08,0.01-0.13,0.02-0.21c0.09-1.06,1.11-1.91,2.27-1.81c4.44,0.37,6.67,0.57,11.11,1c1.09,0.11,2.12-0.72,2.24-1.85 c0.35-3.56,0.53-5.34,0.88-8.91c0.1-1.06-0.76-2.07-1.87-2.18c-4.51-0.44-6.76-0.64-11.28-1.02c-1.18-0.1-2.06-1.1-1.97-2.23 c0.09-1.06,1.12-1.9,2.31-1.8c6.31,0.53,9.47,0.82,15.77,1.46c1.11,0.11,2.17-0.7,2.28-1.84c0.38-3.62,0.57-5.43,0.94-9.04 c0.11-1.06-0.77-2.08-1.9-2.19c-11.16-1.06-22.29-1.98-33.47-2.77c-1.2-0.08-2.24,0.79-2.3,1.85 c-0.89,14.49-1.77,28.97-2.66,43.46c-0.03,0.5,0.13,0.96,0.4,1.33h35.57C224.45,45.8,224.63,44.03,224.95,41z" />
<g>
<path class="st2" d="M185.93,46.8c-2.49-8.91-3.77-13.36-6.42-22.25c-0.06-0.29-0.04-0.71,0.05-0.99 c3.19-8.01,4.82-12,8.14-19.98c0.44-1.04-0.33-2.15-1.53-2.22c-5.45-0.3-8.18-0.43-13.64-0.65c-0.75-0.03-1.45,0.44-1.7,1.07 c-2.18,5.75-3.25,8.62-5.37,14.38c-0.24,0.63-0.92,1.11-1.66,1.08c-0.96-0.03-1.68-0.76-1.65-1.68 c0.16-5.42,0.23-8.14,0.39-13.56c0.03-0.92-0.71-1.65-1.69-1.68c-5.61-0.15-8.41-0.2-14.02-0.26c-0.98-0.01-1.74,0.69-1.75,1.62 c-0.13,14.77-0.26,29.55-0.39,44.32c-0.01,0.92,0.7,1.64,1.63,1.65c5.31,0.06,7.96,0.11,13.26,0.25 c0.93,0.02,1.66-0.66,1.69-1.59c0.14-5.03,0.22-7.54,0.36-12.57c0.03-0.92,0.77-1.61,1.72-1.58c0.06,0,0.09,0,0.15,0 c0.73,0.02,1.29,0.47,1.56,1.12c1.82,5.55,2.72,8.32,4.48,13.88c0.26,0.65,0.81,1.1,1.53,1.13c1.23,0.05,2.31,0.1,3.32,0.14 h11.12C185.9,48,186.1,47.41,185.93,46.8z" />
<g>
<path class="st2" d="M109.16,48.06c1.64-0.06,2.95-1.44,2.9-3.08c-0.14-4.49-0.2-6.73-0.34-11.22 c-0.03-0.92,0.68-1.51,1.48-1.54c0.51-0.01,0.95,0.19,1.25,0.68c3.54,5.28,5.29,7.92,8.72,13.24c0.52,0.99,1.52,1.54,2.67,1.52 c4.62-0.06,6.93-0.08,11.55-0.08c1.64,0,3-1.34,3.01-2.98c0.05-13.87,0.1-27.75,0.16-41.62c0.01-1.63-1.42-2.99-3.16-2.99 c-4.46,0-6.7,0.01-11.16,0.07c-1.73,0.02-3.14,1.39-3.12,3.03c0.07,4.09,0.1,6.14,0.17,10.23c0.01,0.85-0.72,1.43-1.53,1.45 c-0.45,0.01-0.97-0.2-1.27-0.62c-3.6-4.96-5.42-7.43-9.12-12.35c-0.55-0.84-1.62-1.37-2.76-1.34 c-4.88,0.16-7.33,0.26-12.21,0.48c-1.73,0.08-3.1,1.5-3.01,3.13c0.69,13.86,1.39,27.71,2.08,41.57 c0.07,1.37,1.09,2.48,2.38,2.76h2.4C103.45,48.26,105.53,48.18,109.16,48.06z" />
<g>
<path class="st2" d="M90.68,31.54c-0.59-10.75-0.89-16.13-1.48-26.88c-0.1-1.84-1.76-3.25-3.72-3.13 C81.33,1.77,79.25,1.91,75.1,2.2c-1.96,0.14-3.43,1.74-3.29,3.59c0.83,11.11,1.25,16.66,2.08,27.76 c0.23,3.05-1.38,3.88-4.2,4.1c-2.82,0.23-4.61-0.33-4.87-3.37c-0.95-11.1-1.43-16.64-2.38-27.74 c-0.16-1.84-1.86-3.19-3.82-3.02c-4.12,0.37-6.18,0.57-10.29,0.98c-2.03,0.21-3.45,1.86-3.25,3.69 c1.14,10.82,1.72,16.23,2.86,27.05c0.61,6.46,3.39,10.67,7.45,13.16H81.6C87.6,45.46,91.41,40.09,90.68,31.54z" />
<g>
<path class="st2" d="M45.57,39.08c-0.36-3.32-1.02-6.96-4.2-8.75c-1.34-0.76-1.85-2.58-0.95-3.82 c1.9-2.86,1.84-6.21,1.48-9.46c-0.87-7.84-7.46-11.1-19.67-9.49c-7.79,1.03-11.68,1.58-19.45,2.79 c-1.79,0.28-3,1.84-2.74,3.52c1.82,11.52,3.65,23.04,5.47,34.55h37.72C45.2,45.98,45.98,42.89,45.57,39.08z M19.57,41.45 c-0.16-3.39-0.24-5.08-0.39-8.47c-1.42-0.79-2.47-2.16-2.7-3.84c-0.41-2.91,1.72-5.62,4.76-6.03 c3.04-0.41,5.81,1.63,6.19,4.55c0.22,1.69-0.43,3.28-1.59,4.42c0.76,3.31,1.13,4.96,1.88,8.27 C24.46,40.77,22.83,40.99,19.57,41.45z" />
</g>
</g>
<path class="st2" d="M100.25,48.41h-2.4c0.25,0.05,0.5,0.09,0.76,0.08C99.2,48.46,99.73,48.43,100.25,48.41z" />
</g>
</g>
</g>
</g>
<g>
<rect x="89.49" y="51.91" class="st1" width="93.69" height="24.46" />
<g>
<g>
<path class="st0" d="M121.14,73.93l-1.91,0.03l-3.43-9.9l-3.34,9.93l-1.91-0.01l-3.25-14.33l-0.75,0c-0.37,0-0.63-0.07-0.79-0.22 s-0.24-0.34-0.24-0.57c0-0.22,0.08-0.4,0.24-0.55c0.16-0.15,0.42-0.22,0.79-0.22l4.24-0.02c0.37,0,0.63,0.07,0.79,0.22 c0.16,0.15,0.24,0.34,0.24,0.57c0,0.22-0.08,0.4-0.24,0.55s-0.42,0.22-0.79,0.22l-1.96,0.01l2.78,12.22l3.25-9.78l1.84-0.01 l3.42,9.76l2.58-12.24l-1.95,0.01c-0.37,0-0.63-0.07-0.8-0.22c-0.16-0.15-0.25-0.34-0.25-0.57c0-0.22,0.08-0.4,0.24-0.55 c0.16-0.15,0.43-0.22,0.81-0.22l4.22-0.02c0.38,0,0.65,0.07,0.81,0.22c0.16,0.15,0.24,0.34,0.24,0.57c0,0.22-0.08,0.4-0.24,0.55 s-0.43,0.22-0.81,0.22l-0.73,0L121.14,73.93z" />
<path class="st0" d="M146.71,66.25l-15.72,0.06c0.28,1.99,1.12,3.6,2.52,4.81c1.41,1.21,3.14,1.82,5.21,1.81 c1.15,0,2.36-0.2,3.62-0.58c1.26-0.38,2.29-0.89,3.08-1.52c0.23-0.18,0.43-0.28,0.6-0.28c0.2,0,0.37,0.08,0.51,0.23 c0.15,0.15,0.22,0.33,0.22,0.54s-0.1,0.41-0.29,0.61c-0.59,0.61-1.63,1.19-3.12,1.73c-1.5,0.54-3.04,0.81-4.62,0.82 c-2.64,0.01-4.85-0.85-6.63-2.57c-1.78-1.72-2.67-3.82-2.68-6.28c-0.01-2.24,0.81-4.17,2.47-5.78s3.7-2.42,6.15-2.43 c2.52-0.01,4.6,0.81,6.23,2.45C145.91,61.5,146.72,63.63,146.71,66.25z M145.14,64.7c-0.31-1.7-1.12-3.08-2.43-4.14 c-1.31-1.06-2.86-1.59-4.66-1.58c-1.8,0.01-3.35,0.54-4.64,1.6s-2.1,2.45-2.41,4.18L145.14,64.7z" />
<path class="st0" d="M155.06,50.76l0.04,10.23c1.85-2.43,4.09-3.65,6.73-3.66c2.25-0.01,4.18,0.8,5.79,2.43 c1.61,1.63,2.42,3.63,2.43,6.01c0.01,2.4-0.8,4.43-2.41,6.11c-1.62,1.67-3.53,2.51-5.75,2.52c-2.69,0.01-4.94-1.19-6.75-3.61 l0.01,3.03l-3.62,0.01c-0.37,0-0.63-0.07-0.79-0.22c-0.16-0.15-0.24-0.33-0.24-0.55c0-0.23,0.08-0.42,0.24-0.56 c0.16-0.14,0.42-0.21,0.79-0.21l2.07-0.01l-0.07-19.94l-2.07,0.01c-0.37,0-0.63-0.07-0.79-0.22c-0.16-0.15-0.24-0.34-0.24-0.57 c0-0.22,0.08-0.4,0.24-0.55c0.16-0.15,0.42-0.22,0.79-0.22L155.06,50.76z M168.5,65.84c-0.01-1.95-0.68-3.59-2.02-4.94 c-1.34-1.35-2.9-2.02-4.69-2.01s-3.35,0.69-4.67,2.05c-1.33,1.36-1.99,3.01-1.98,4.96c0.01,1.95,0.68,3.59,2.02,4.94 c1.34,1.35,2.9,2.02,4.69,2.01c1.79-0.01,3.35-0.69,4.67-2.05C167.85,69.44,168.51,67.79,168.5,65.84z" />
</g>
</g>
</g>
</svg>
<span class="app-brand-logo main w-100">
<img class="img-fluid" src="img/logo-menu.png" alt="BunkerWeb logo">
</span>
</a>
<a href="javascript:void(0);"
@ -65,7 +18,7 @@
"home": {"url": url_for('home'), "icon": "bx-home-smile"},
"instances": {"url": url_for('instances'), "icon": "bx-server"},
"global-config": {"url": url_for('global_config'), "icon": "bx-cog"},
"services": {"url": url_for('services'), "icon": "bx-cube", "sub": services, "max": 5},
"services": {"url": url_for('services'), "icon": "bx-cube"},
"configs": {"url": url_for('configs'), "icon": "bx-wrench"},
"plugins": {"url": url_for('plugins'), "icon": "bx-plug"},
"cache": {"url": url_for('cache'), "icon": "bx-data"},
@ -123,26 +76,7 @@
</div>
{% else %}
<div class="badge badge-center rounded-pill text-uppercase fs-tiny ms-auto">
<svg width="19"
height="15"
viewBox="0 0 19 15"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_226_466)">
<path d="M16.8356 1.98041L13.0948 3.96082H5.88436L2.16443 1.98041L5.80095 0H13.1991L16.8356 1.98041Z" fill="#349F53" />
<path d="M19 4.90339L14.542 7.42183L13.0948 3.96088L16.8356 1.98047L19 4.90339Z" fill="#227848" />
<path d="M19 4.90332L9.47913 15L14.5419 7.42176L19 4.90332Z" fill="#194B34" />
<path d="M5.88433 3.96088L4.30795 7.42183L0 4.90339L2.1644 1.98047L5.88433 3.96088Z" fill="#249C59" />
<path d="M9.47915 15L0 4.90332L4.30795 7.42176L9.47915 15Z" fill="#237F4C" />
<path d="M14.542 7.42175L9.47919 15L4.30798 7.42175H14.542Z" fill="#349F53" />
<path d="M14.542 7.42177H4.30798L5.88437 3.96082H13.0949L14.542 7.42177Z" fill="#65B278" />
</g>
<defs>
<clipPath id="clip0_226_466">
<rect width="19" height="15" fill="white" />
</clipPath>
</defs>
</svg>
<img src="img/diamond.svg" alt="Pro plugin" width="18px" height="15.5px">
</div>
{% endif %}
</a>