Skip to content

Commit

Permalink
Finished v1 of instances page in web UI
Browse files Browse the repository at this point in the history
  • Loading branch information
TheophileDiot committed Sep 1, 2024
1 parent bff3567 commit aa11740
Show file tree
Hide file tree
Showing 15 changed files with 616 additions and 258 deletions.
31 changes: 30 additions & 1 deletion src/common/db/Database.py
Original file line number Diff line number Diff line change
Expand Up @@ -3154,6 +3154,34 @@ def add_instance(self, hostname: str, port: int, server_name: str, method: str,

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:
Expand Down Expand Up @@ -3236,7 +3264,8 @@ def update_instance(self, hostname: str, status: str) -> str:
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()
Expand Down
1 change: 0 additions & 1 deletion src/common/utils/ApiCaller.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ def send_request(api, files):
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:
Expand Down
4 changes: 2 additions & 2 deletions src/scheduler/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -811,13 +811,13 @@ def check_plugin_changes(_type: Literal["external", "pro"] = "external"):

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}")
Expand Down
44 changes: 22 additions & 22 deletions src/ui/app/models/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,72 +69,72 @@ def reload(self) -> str:
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)
Expand All @@ -145,16 +145,16 @@ def metrics(self, plugin_id) -> Tuple[bool, dict[str, Any]]:
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]]:
Expand Down
120 changes: 91 additions & 29 deletions src/ui/app/routes/instances.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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

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)

ret = DB.delete_instance(instance_hostname)
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 action == "ping":
succeed = []
failed = []

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)}"),
)
)
39 changes: 4 additions & 35 deletions src/ui/app/static/css/core.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
Loading

0 comments on commit aa11740

Please sign in to comment.