Add configs and cache page to web UI

This commit is contained in:
Théophile Diot 2024-09-19 16:11:32 +02:00
parent 492b5b1944
commit b75a0fe5f5
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
24 changed files with 1980 additions and 379 deletions

View file

@ -1943,30 +1943,28 @@ class Database:
return config
def get_custom_configs(self, *, with_drafts: bool = False, as_dict: bool = False) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
def get_custom_configs(self, *, with_drafts: bool = False, with_data: bool = True, as_dict: bool = False) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
"""Get the custom configs from the database"""
db_config = self.get_non_default_settings(with_drafts=with_drafts, filtered_settings={"USE_TEMPLATE"})
with self._db_session() as session:
custom_configs = [
{
entities = [Custom_configs.service_id, Custom_configs.type, Custom_configs.name, Custom_configs.checksum, Custom_configs.method]
if with_data:
entities.append(Custom_configs.data)
custom_configs = []
for custom_config in session.query(Custom_configs).with_entities(*entities):
data = {
"service_id": custom_config.service_id,
"type": custom_config.type,
"name": custom_config.name,
"data": custom_config.data,
"checksum": custom_config.checksum,
"method": custom_config.method,
"template": None,
}
for custom_config in (
session.query(Custom_configs).with_entities(
Custom_configs.service_id,
Custom_configs.type,
Custom_configs.name,
Custom_configs.data,
Custom_configs.method,
)
)
]
if with_data:
data["data"] = custom_config.data
custom_configs.append(data)
if not db_config:
if as_dict:
@ -1978,30 +1976,31 @@ class Database:
return dict_custom_configs
return custom_configs
template_entities = [Template_custom_configs.type, Template_custom_configs.name, Template_custom_configs.checksum]
if with_data:
template_entities.append(Template_custom_configs.data)
for service in session.query(Services).with_entities(Services.id).all():
for key, value in db_config.items():
if key.startswith(f"{service.id}_"):
for template_config in (
session.query(Template_custom_configs)
.with_entities(Template_custom_configs.type, Template_custom_configs.name, Template_custom_configs.data)
.filter_by(template_id=value)
):
for template_config in session.query(Template_custom_configs).with_entities(*template_entities).filter_by(template_id=value):
if not any(
custom_config["service_id"] == service.id
and custom_config["type"] == template_config.type
and custom_config["name"] == template_config.name
for custom_config in custom_configs
):
custom_configs.append(
{
"service_id": service.id,
"type": template_config.type,
"name": template_config.name,
"data": template_config.data,
"method": "default",
"template": value,
}
)
custom_config = {
"service_id": service.id,
"type": template_config.type,
"name": template_config.name,
"checksum": template_config.checksum,
"method": "default",
"template": value,
}
if with_data:
custom_config["data"] = template_config.data
custom_configs.append(custom_config)
if as_dict:
dict_custom_configs = {}
@ -2012,6 +2011,102 @@ class Database:
return dict_custom_configs
return custom_configs
def get_custom_config(self, config_type: str, name: str, *, service_id: Optional[str] = None, with_data: bool = True) -> Dict[str, Any]:
"""Get a custom config from the database"""
with self._db_session() as session:
entities = [Custom_configs.service_id, Custom_configs.type, Custom_configs.name, Custom_configs.checksum, Custom_configs.method]
if with_data:
entities.append(Custom_configs.data)
db_config = session.query(Custom_configs).with_entities(*entities).filter_by(service_id=service_id, type=config_type, name=name).first()
if not db_config:
if service_id:
service_config = self.get_non_default_settings(with_drafts=True, filtered_settings={"USE_TEMPLATE"})
if service_config.get(f"{service_id}_USE_TEMPLATE"):
with self._db_session() as session:
template_config = (
session.query(Template_custom_configs)
.filter_by(template_id=service_config.get(f"{service_id}_USE_TEMPLATE"), type=config_type, name=name)
.first()
)
if template_config:
custom_config = {
"service_id": service_id,
"type": config_type,
"name": name,
"checksum": template_config.checksum,
"method": "default",
"template": service_config.get(f"{service_id}_USE_TEMPLATE"),
}
if with_data:
custom_config["data"] = template_config.data
return custom_config
return {}
custom_config = {
"service_id": service_id,
"type": config_type,
"name": name,
"checksum": db_config.checksum,
"method": db_config.method,
"template": None,
}
if with_data:
custom_config["data"] = db_config.data
return custom_config
def upsert_custom_config(self, config_type: str, name: str, config: Dict[str, Any], *, service_id: Optional[str] = None) -> str:
"""Update or insert a custom config in the database"""
with self._db_session() as session:
if self.readonly:
return "The database is read-only, the changes will not be saved"
filters = {
"type": config_type,
"name": name,
}
if service_id:
filters["service_id"] = service_id
custom_config = session.query(Custom_configs).filter_by(**filters).first()
data = config["data"].encode("utf-8") if isinstance(config["data"], str) else config["data"]
checksum = config.get("checksum", bytes_hash(data, algorithm="sha256"))
if not custom_config:
session.add(
Custom_configs(
service_id=config.get("service_id"),
type=config["type"],
name=config["name"],
data=data,
checksum=checksum,
method=config["method"],
)
)
else:
custom_config.service_id = config.get("service_id")
custom_config.data = data
custom_config.checksum = checksum
for key in ("type", "name", "method"):
if key in config:
setattr(custom_config, key, config[key])
with suppress(ProgrammingError, OperationalError):
metadata = session.query(Metadata).get(1)
if metadata is not None:
metadata.custom_configs_changed = True
metadata.last_custom_configs_change = datetime.now().astimezone()
try:
session.commit()
except BaseException as e:
return str(e)
return ""
def get_services_settings(self, methods: bool = False, with_drafts: bool = False) -> List[Dict[str, Any]]:
"""Get the services' configs from the database"""
services = []
@ -3145,11 +3240,14 @@ class Database:
ret_data["data"] = data.data
return ret_data
def get_jobs_cache_files(self, *, job_name: str = "", plugin_id: str = "") -> List[Dict[str, Any]]:
def get_jobs_cache_files(self, *, with_data: bool = True, job_name: str = "", plugin_id: str = "") -> List[Dict[str, Any]]:
"""Get jobs cache files."""
with self._db_session() as session:
filters = {}
query = session.query(Jobs_cache).with_entities(Jobs_cache.job_name, Jobs_cache.service_id, Jobs_cache.file_name, Jobs_cache.data)
entities = [Jobs_cache.job_name, Jobs_cache.service_id, Jobs_cache.file_name, Jobs_cache.last_update, Jobs_cache.checksum]
if with_data:
entities.append(Jobs_cache.data)
query = session.query(Jobs_cache).with_entities(*entities)
if job_name:
query = query.filter_by(job_name=job_name)
@ -3185,9 +3283,13 @@ class Database:
"job_name": cache.job_name,
"service_id": cache.service_id,
"file_name": cache.file_name,
"data": cache.data,
"last_update": cache.last_update if cache.last_update is not None else "Never",
"checksum": cache.checksum,
}
)
if with_data:
cache_files[-1]["data"] = cache.data
return cache_files
def add_instance(self, hostname: str, port: int, server_name: str, method: str, changed: Optional[bool] = True, *, name: Optional[str] = None) -> str:

View file

@ -155,7 +155,7 @@ class Config:
elif setting not in config and plugins_settings[setting]["default"] == v:
variables.pop(k)
continue
elif not new and setting != "IS_DRAFT" and config[setting]["method"] not in ("default", "ui"):
elif not new and setting != "IS_DRAFT" and setting in config 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"})

View file

@ -1,26 +1,47 @@
from os.path import join, sep
from io import BytesIO
from flask import Blueprint, render_template
from flask import Blueprint, Response, flash, redirect, render_template, request, send_file, url_for
from flask_login import login_required
from magic import Magic
from app.dependencies import BW_CONFIG, DB
from app.utils import path_to_dict
from app.dependencies import DB
cache = Blueprint("cache", __name__)
SHOWN_FILE_TYPES = ("text/plain", "text/html", "text/css", "text/javascript", "application/json", "application/xml")
@cache.route("/cache", methods=["GET"])
@login_required
def cache_page(): # TODO: refactor this function
return render_template(
"cache.html",
folders=[
path_to_dict(
join(sep, "var", "cache", "bunkerweb"),
is_cache=True,
db_data=DB.get_jobs_cache_files(),
services=BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME",)).get("SERVER_NAME", "").split(" "),
)
],
def cache_page():
return render_template("cache.html", caches=DB.get_jobs_cache_files(with_data=False))
@cache.route("/cache/<string:service>/<string:plugin_id>/<string:job_name>/<string:file_name>", methods=["GET"])
@login_required
def cache_view(service: str, plugin_id: str, job_name: str, file_name: str):
file_name = file_name.replace("_", "/")
cache_file = DB.get_job_cache_file(
job_name,
file_name,
service_id=service if service != "global" else None,
plugin_id=plugin_id,
)
download = request.args.get("download", False)
if download:
if not cache_file:
return Response("Cache file not found", status=404)
return send_file(BytesIO(cache_file), as_attachment=True, download_name=file_name)
if not cache_file:
flash(f"Cache file {file_name} from job {job_name}, plugin {plugin_id}{', service ' + service if service != 'global' else ''} not found", "error")
return redirect(url_for("cache.cache_page"))
file_type = Magic(mime=True).from_buffer(cache_file)
return render_template(
"cache_view.html",
cache_file=cache_file.decode("utf-8") if file_type in SHOWN_FILE_TYPES else f"File is of type {file_type}, Download it to view the content",
)

View file

@ -1,132 +1,338 @@
from copy import deepcopy
from os.path import join, sep
from json import JSONDecodeError, loads
from re import match
from threading import Thread
from time import time
from typing import Dict, Literal, Optional
from bs4 import BeautifulSoup
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from app.dependencies import BW_CONFIG, DATA, DB # TODO: remember about DATA.load_from_file()
from app.utils import LOGGER, PLUGIN_NAME_RX, path_to_dict
from app.routes.utils import handle_error, verify_data_in_form
from app.routes.utils import handle_error, verify_data_in_form, wait_applying
configs = Blueprint("configs", __name__)
CONFIG_TYPES = {
"HTTP": {"context": "global", "description": "Configurations at the HTTP level of NGINX."},
"SERVER_HTTP": {"context": "multisite", "description": "Configurations at the HTTP/Server level of NGINX."},
"DEFAULT_SERVER_HTTP": {
"context": "global",
"description": 'Configurations at the Server level of NGINX, specifically for the "default server" when the supplied client name doesn\'t match any server name in SERVER_NAME.',
},
"MODSEC_CRS": {"context": "multisite", "description": "Configurations applied before the OWASP Core Rule Set is loaded."},
"MODSEC": {
"context": "multisite",
"description": "Configurations applied after the OWASP Core Rule Set is loaded, or used when the Core Rule Set is not loaded.",
},
"STREAM": {"context": "global", "description": "Configurations at the Stream level of NGINX."},
"SERVER_STREAM": {"context": "multisite", "description": "Configurations at the Stream/Server level of NGINX."},
"CRS_PLUGINS_BEFORE": {"context": "multisite", "description": "Configurations applied before the OWASP Core Rule Set plugins are loaded."},
"CRS_PLUGINS_AFTER": {"context": "multisite", "description": "Configurations applied after the OWASP Core Rule Set plugins are loaded."},
}
@configs.route("/configs", methods=["GET", "POST"])
@configs.route("/configs", methods=["GET"])
@login_required
def configs_page(): # TODO: refactor this function
db_configs = DB.get_custom_configs()
def configs_page():
return render_template("configs.html", configs=DB.get_custom_configs(with_drafts=True, with_data=False))
@configs.route("/configs/delete", methods=["POST"])
@login_required
def configs_delete():
verify_data_in_form(
data={"configs": None},
err_message="Missing configs parameter on /configs/delete.",
redirect_url="configs",
next=True,
)
configs = request.form["configs"]
if not configs:
return handle_error("No configs selected.", "configs", True)
try:
configs = loads(configs)
except JSONDecodeError:
return handle_error("Invalid configs parameter on /configs/delete.", "configs", True)
DATA.load_from_file()
def delete_configs(configs: Dict[str, str]):
wait_applying()
db_configs = DB.get_custom_configs(with_drafts=True)
configs_to_delete = set()
non_ui_configs = set()
for i, db_config in enumerate(db_configs):
for config in configs:
if db_config["name"] == config["name"] and db_config["service_id"] == config["service"] and db_config["type"] == config["type"]:
key = f"{(config['service'] + '/') if config['service'] else ''}{config['type']}/{config['name']}"
if db_config["method"] != "ui":
non_ui_configs.add(key)
continue
configs_to_delete.add(key)
del db_configs[i]
for non_ui_config in non_ui_configs:
DATA["TO_FLASH"].append(
{
"content": f"Custom config {non_ui_config} is not a UI custom config and will not be deleted.",
"type": "error",
}
)
if not configs_to_delete:
DATA["TO_FLASH"].append({"content": "All selected custom configs could not be found or are not UI custom configs.", "type": "error"})
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
error = DB.save_custom_configs(db_configs, "ui")
if error:
DATA["TO_FLASH"].append({"content": f"An error occurred while saving the custom configs: {error}", "type": "error"})
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
DATA["TO_FLASH"].append({"content": f"Deleted config{'s' if len(configs_to_delete) > 1 else ''}: {', '.join(configs_to_delete)}", "type": "success"})
DATA["RELOADING"] = False
DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True})
Thread(target=delete_configs, args=(configs,)).start()
return redirect(
url_for(
"loading",
next=url_for("configs.configs_page"),
message=f"Deleting selected config{'s' if len(configs) > 1 else ''}",
)
)
@configs.route("/configs/new", methods=["GET", "POST"])
@login_required
def configs_new():
if request.method == "POST":
if DB.readonly:
return handle_error("Database is in read-only mode", "configs")
operation = ""
verify_data_in_form(
data={"operation": ("new", "edit", "delete"), "type": "file", "path": None},
err_message="Invalid operation parameter on /configs.",
redirect_url="configs",
data={"service": None},
err_message="Missing service parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
service = request.form["service"]
services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ")
if service != "no service" and service not in services:
return handle_error(f"Service {service} does not exist.", "configs/new", True)
# Check variables
variables = deepcopy(request.form.to_dict())
del variables["csrf_token"]
verify_data_in_form(
data={"type": None},
err_message="Missing type parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
config_type = request.form["type"]
if config_type not in CONFIG_TYPES:
return handle_error("Invalid type parameter on /configs/new.", "configs/new", True)
if variables["type"] != "file":
return handle_error("Invalid type parameter on /configs.", "configs", True)
verify_data_in_form(
data={"name": None},
err_message="Missing name parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
config_name = request.form["name"]
if not match(r"^[\w_-]{1,64}$", config_name):
return handle_error("Invalid name parameter on /configs/new.", "configs/new", True)
# TODO: revamp this to use a path but a form to edit the content
verify_data_in_form(
data={"value": None},
err_message="Missing value parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
config_value = request.form["value"].replace("\r\n", "\n").strip()
DATA.load_from_file()
# operation = BW_CUSTOM_CONFIGS.check_path(variables["path"])
def create_config(
service: Optional[str],
config_type: Literal[
"HTTP", "SERVER_HTTP", "DEFAULT_SERVER_HTTP", "MODSEC_CRS", "MODSEC", "STREAM", "SERVER_STREAM", "CRS_PLUGINS_BEFORE", "CRS_PLUGINS_AFTER"
],
config_name: str,
config_value: str,
):
wait_applying()
config_type = config_type.lower()
# if operation:
# return handle_error(operation, "configs", True)
db_configs = DB.get_custom_configs(with_drafts=True)
for i, db_config in enumerate(db_configs.copy()):
if db_config["method"] == "default" and db_config["template"]:
del db_configs[i]
continue
old_name = variables.get("old_name", "").replace(".conf", "")
name = variables.get("name", old_name).replace(".conf", "")
path_exploded = variables["path"].split(sep)
service_id = (path_exploded[5] if len(path_exploded) > 6 else None) or None
root_dir = path_exploded[4].replace("-", "_").lower()
if not old_name and not name:
return handle_error("Missing name parameter on /configs.", "configs", True)
index = -1
for i, db_config in enumerate(db_configs):
if db_config["type"] == root_dir and db_config["name"] == name and db_config["service_id"] == service_id:
if request.form["operation"] == "new":
return handle_error(f"Config {name} already exists{f' for service {service_id}' if service_id else ''}", "configs", True)
elif db_config["method"] not in ("ui", "manual"):
return handle_error(
f"Can't edit config {name}{f' for service {service_id}' if service_id else ''} because it was not created by the UI or manually",
"configs",
True,
if db_config["type"] == config_type and db_config["name"] == config_name and db_config["service_id"] == service:
DATA["TO_FLASH"].append(
{
"content": f"Config {config_type}/{config_name}{' for service ' + service if service else ''} already exists",
"type": "error",
}
)
index = i
break
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
# New or edit a config
if request.form["operation"] in ("new", "edit"):
if not PLUGIN_NAME_RX.match(name):
return handle_error(
f"Invalid {variables['type']} name. (Can only contain numbers, letters, underscores, dots and hyphens (min 4 characters and max 64))",
"configs",
True,
)
db_configs[i] = {
"service_id": db_config["service_id"],
"type": db_config["type"],
"name": db_config["name"],
"data": db_config["data"],
"method": db_config["method"],
}
if "checksum" in db_config:
db_configs[i]["checksum"] = db_config["checksum"]
content = BeautifulSoup(variables["content"], "html.parser").get_text()
new_config = {
"type": config_type,
"name": config_name,
"data": config_value,
"method": "ui",
}
if service != "no service":
new_config["service_id"] = service
db_configs.append(new_config)
if request.form["operation"] == "new":
db_configs.append({"type": root_dir, "name": name, "service_id": service_id, "data": content, "method": "ui"})
operation = f"Created config {name}{f' for service {service_id}' if service_id else ''}"
elif request.form["operation"] == "edit":
if index == -1:
return handle_error(
f"Can't edit config {name}{f' for service {service_id}' if service_id else ''} because it doesn't exist", "configs", True
)
error = DB.save_custom_configs(db_configs, "ui")
if error:
DATA["TO_FLASH"].append({"content": f"An error occurred while saving the custom configs: {error}", "type": "error"})
return
DATA["TO_FLASH"].append(
{
"content": f"Created custom configuration {config_type}/{config_name}{' for service ' + service if service else ''}",
"type": "success",
}
)
DATA["RELOADING"] = False
if old_name != name:
db_configs[index]["name"] = name
elif db_configs[index]["data"] == content:
return handle_error(
f"Config {name} was not edited because no values were changed{f' for service {service_id}' if service_id else ''}",
"configs",
True,
)
DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True})
Thread(target=create_config, args=(service if service != "no service" else None, config_type, config_name, config_value)).start()
db_configs[index]["data"] = content
operation = f"Edited config {name}{f' for service {service_id}' if service_id else ''}"
return redirect(
url_for(
"loading",
next=url_for(
"configs.configs_edit", service="global" if service == "no service" else service, config_type=config_type.lower(), name=config_name
),
message=f"Creating custom configuration {config_type}/{config_name}{' for service' + service if service != 'no service' else ''}",
)
)
# Delete a config
elif request.form["operation"] == "delete":
if index == -1:
return handle_error(f"Can't delete config {name}{f' for service {service_id}' if service_id else ''} because it doesn't exist", "configs", True)
clone = request.args.get("clone", "")
config_service = ""
config_type = ""
config_name = ""
del db_configs[index]
operation = f"Deleted config {name}{f' for service {service_id}' if service_id else ''}"
if clone:
config_service, config_type, config_name = clone.split("/")
db_custom_config = DB.get_custom_config(config_type, config_name, service_id=config_service if config_service != "global" else None, with_data=True)
clone = db_custom_config.get("data", b"").decode("utf-8")
return render_template(
"config_edit.html", config_types=CONFIG_TYPES, config_value=clone, config_service=config_service, type=config_type.upper(), name=config_name
)
error = DB.save_custom_configs([config for config in db_configs if config["method"] == "ui"], "ui")
@configs.route("/configs/<string:service>/<string:config_type>/<string:name>", methods=["GET", "POST"]) # TODO: finish saving
@login_required
def configs_edit(service: str, config_type: str, name: str):
if service == "global":
service = None
db_config = DB.get_custom_config(config_type, name, service_id=service, with_data=True)
if not db_config:
return handle_error(f"Config {config_type}/{name}{' for service ' + service if service else ''} does not exist.", "configs", True)
if request.method == "POST":
if not db_config["template"] and db_config["method"] != "ui":
return handle_error(
f"Config {config_type}/{name}{' for service ' + service if service else ''} is not a UI custom config and cannot be edited.", "configs", True
)
verify_data_in_form(
data={"service": None},
err_message="Missing service parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
new_service = request.form["service"]
services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ")
if new_service != "no service" and new_service not in services:
return handle_error(f"Service {new_service} does not exist.", "configs/new", True)
if new_service == "no service":
new_service = None
verify_data_in_form(
data={"type": None},
err_message="Missing type parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
new_type = request.form["type"]
if new_type not in CONFIG_TYPES:
return handle_error("Invalid type parameter on /configs/new.", "configs/new", True)
new_type = new_type.lower()
verify_data_in_form(
data={"name": None},
err_message="Missing name parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
new_name = request.form["name"]
if not match(r"^[\w_-]{1,64}$", new_name):
return handle_error("Invalid name parameter on /configs/new.", "configs/new", True)
verify_data_in_form(
data={"value": None},
err_message="Missing value parameter on /configs/new.",
redirect_url="configs/new",
next=True,
)
config_value = request.form["value"].replace("\r\n", "\n").strip()
DATA.load_from_file()
if config_value == db_config["data"].decode("utf-8").strip():
return handle_error("No values were changed.", "configs", True)
error = DB.upsert_custom_config(
config_type,
name,
{
"service_id": service,
"type": new_type,
"name": new_name,
"data": config_value,
"method": "ui",
},
service_id=new_service,
)
if error:
LOGGER.error(f"Could not save custom configs: {error}")
return handle_error("Couldn't save custom configs", "configs", True)
flash(f"An error occurred while saving the custom configs: {error}", "error")
else:
flash(f"Saved custom configuration {new_type}/{new_name}{' for service ' + new_service if new_service else ''}", "success")
DATA["CONFIG_CHANGED"] = True
flash(operation)
return redirect(url_for("loading", next=url_for("configs.configs_page"), message="Update configs"))
return redirect(
url_for(
"configs.configs_edit",
service=new_service,
config_type=new_type,
name=new_name,
)
)
return render_template(
"configs.html",
folders=[
path_to_dict(
join(sep, "etc", "bunkerweb", "configs"),
db_data=db_configs,
services=BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME",)).get("SERVER_NAME", "").split(" "),
)
],
"config_edit.html",
config_types=CONFIG_TYPES,
config_value=db_config["data"].decode("utf-8"),
config_service=db_config.get("service_id"),
type=db_config["type"].upper(),
name=db_config["name"],
config_method=db_config["method"],
config_template=db_config.get("template"),
)

View file

@ -74,7 +74,7 @@ def instances_action(action: Literal["ping", "reload", "stop", "delete"]): # TO
)
instances = request.form["instances"].split(",")
if not instances:
return handle_error("No instances selected.", "instances", True)
return handle_error(f"No instance{'s' if len(instances) > 1 else ''} selected.", "instances", True)
DATA.load_from_file()
if action == "ping":
@ -114,12 +114,16 @@ def instances_action(action: Literal["ping", "reload", "stop", "delete"]): # TO
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)
return handle_error(
f"{'All selected instances' if len(delete_instances) > 1 else 'Selected instance'} could not be found or {'are not UI instances' if len(delete_instances) > 1 else 'is not an UI instance'}.",
"instances",
True,
)
ret = DB.delete_instances(delete_instances)
if ret:
return handle_error(f"Couldn't delete the instances in the database: {ret}", "instances", True)
flash(f"Instances {', '.join(delete_instances)} deleted successfully.", "success")
return handle_error(f"Couldn't delete the instance{'s' if len(delete_instances) > 1 else ''} in the database: {ret}", "instances", True)
flash(f"Instance{'s' if len(delete_instances) > 1 else ''} {', '.join(delete_instances)} deleted successfully.", "success")
else:
def execute_action(instance):
@ -152,6 +156,6 @@ def instances_action(action: Literal["ping", "reload", "stop", "delete"]): # TO
url_for(
"loading",
next=url_for("instances.instances_page"),
message=(f"{ACTIONS[action]['present']} instances {', '.join(instances)}"),
message=(f"{ACTIONS[action]['present']} instance{'s' if len(instances) > 1 else ''} {', '.join(instances)}"),
)
)

View file

@ -151,7 +151,7 @@ def services_delete():
DATA["TO_FLASH"].append({"content": ret, "type": "error"})
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
DATA["TO_FLASH"].append({"content": f"Deleted services: {', '.join(services_to_delete)}", "type": "success"})
DATA["TO_FLASH"].append({"content": f"Deleted service{'s' if len(services_to_delete) > 1 else ''}: {', '.join(services_to_delete)}", "type": "success"})
DATA["RELOADING"] = False
DATA.update({"RELOADING": True, "LAST_RELOAD": time(), "CONFIG_CHANGED": True})
@ -206,7 +206,7 @@ def services_service_page(service: str):
if mode == "easy":
db_templates = DB.get_templates()
db_custom_configs = DB.get_custom_configs(as_dict=True)
db_custom_configs = DB.get_custom_configs(with_drafts=True, as_dict=True)
for variable, value in variables.copy().items():
conf_match = CUSTOM_CONF_RX.match(variable)
@ -343,7 +343,7 @@ def services_service_page(service: str):
search_type = request.args.get("type", "all")
template = request.args.get("template", "high")
db_templates = DB.get_templates()
db_custom_configs = DB.get_custom_configs(as_dict=True)
db_custom_configs = DB.get_custom_configs(with_drafts=True, as_dict=True)
clone = None
if service == "new":
clone = request.args.get("clone", "")

View file

@ -609,3 +609,18 @@ a.badge:hover {
color: #6a9955;
font-style: italic;
}
table.table.dataTable > tbody > tr.selected a {
color: #fff;
}
table.table.dataTable > tbody > tr.selected .btn-outline-secondary {
color: #fff !important;
border-color: #fff !important;
}
table.table.dataTable > tbody > tr.selected .btn-outline-secondary:hover {
color: black !important;
background-color: #fff !important;
border-color: #fff !important;
}

View file

@ -0,0 +1,133 @@
$(document).ready(function () {
const cacheNumber = parseInt($("#cache_number").val());
const layout = {
topStart: {},
bottomEnd: {},
};
if (cacheNumber > 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(:last-child)",
text: '<span class="tf-icons bx bx-columns bx-18px me-2"></span>Columns',
className: "btn btn-sm btn-outline-primary",
columnText: function (dt, idx, title) {
return idx + 1 + ". " + title;
},
},
{
extend: "colvisRestore",
text: '<span class="tf-icons bx bx-reset bx-18px me-2"></span>Reset<span class="d-none d-md-inline"> columns</span>',
className: "btn btn-sm btn-outline-primary",
},
{
extend: "collection",
text: '<span class="tf-icons bx bx-export bx-18px me-2"></span>Export',
className: "btn btn-sm btn-outline-primary",
buttons: [
{
extend: "copy",
text: '<span class="tf-icons bx bx-copy bx-18px me-2"></span>Copy current page',
exportOptions: {
modifier: {
page: "current",
},
},
},
{
extend: "csv",
text: '<span class="tf-icons bx bx-table bx-18px me-2"></span>CSV',
bom: true,
filename: "bw_cache",
exportOptions: {
modifier: {
search: "none",
},
},
},
{
extend: "excel",
text: '<span class="tf-icons bx bx-table bx-18px me-2"></span>Excel',
filename: "bw_cache",
exportOptions: {
modifier: {
search: "none",
},
},
},
],
},
];
const cache_table = new DataTable("#cache", {
columnDefs: [
{
orderable: false,
targets: -1,
},
{
visible: false,
targets: 5,
},
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
$(td).addClass("text-center align-items-center"); // Apply 'text-center' class to <td>
},
},
],
order: [[2, "asc"]],
autoFill: false,
responsive: true,
layout: layout,
language: {
info: "Showing _START_ to _END_ of _TOTAL_ cache files",
infoEmpty: "No cache files available",
infoFiltered: "(filtered from _MAX_ total cache files)",
lengthMenu: "Display _MENU_ cache files",
zeroRecords: "No matching cache files found",
select: {
rows: {
_: "Selected %d cache files",
0: "No cache files selected",
1: "Selected 1 cache file",
},
},
},
initComplete: function (settings, json) {
$("#cache_wrapper .btn-secondary").removeClass("btn-secondary");
},
});
cache_table.on("mouseenter", "td", function () {
const rowIdx = cache_table.cell(this).index().row;
cache_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
cache_table
.cells()
.nodes()
.each(function (el) {
if (cache_table.cell(el).index().row === rowIdx)
el.classList.add("highlight");
});
});
cache_table.on("mouseleave", "td", function () {
cache_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
});
});

View file

@ -0,0 +1,21 @@
$(document).ready(function () {
const editorElement = $("#cache-value");
const initialContent = editorElement.text().trim();
const editor = ace.edit(editorElement[0]);
editor.setTheme("ace/theme/cloud9_day"); // cloud9_night when dark mode is supported
editor.session.setMode("ace/mode/text");
editor.setReadOnly(true);
// Set the editor's initial content
editor.setValue(initialContent, -1); // The second parameter moves the cursor to the start
editor.setOptions({
fontSize: "14px",
showPrintMargin: false,
tabSize: 2,
useSoftTabs: true,
wrap: true,
});
editor.renderer.setScrollMargin(10, 10);
});

View file

@ -0,0 +1,248 @@
$(document).ready(function () {
let selectedService = $("#selected-service").val().trim();
const originalService = selectedService;
let selectedType = $("#selected-type").val().trim();
const originalType = selectedType;
const originalName = $("#config-name").val().trim();
const editorElement = $("#config-value");
const initialContent = editorElement.text().trim();
const editor = ace.edit(editorElement[0]);
editor.setTheme("ace/theme/cloud9_day"); // cloud9_night when dark mode is supported
const language = editorElement.data("language"); // TODO: Support ModSecurity
if (language === "NGINX") {
editor.session.setMode("ace/mode/nginx");
} else {
editor.session.setMode("ace/mode/text"); // Default mode if language is unrecognized
}
const method = editorElement.data("method");
const template = editorElement.data("template");
if (method !== "ui" && template === "") {
editor.setReadOnly(true);
}
// Set the editor's initial content
editor.setValue(initialContent, -1); // The second parameter moves the cursor to the start
editor.setOptions({
fontSize: "14px",
showPrintMargin: false,
tabSize: 2,
useSoftTabs: true,
wrap: true,
});
editor.renderer.setScrollMargin(10, 10);
const $serviceSearch = $("#service-search");
const $serviceDropdownMenu = $("#services-dropdown-menu");
const $serviceDropdownItems = $("#services-dropdown-menu li.nav-item");
const $typeDropdownItems = $("#types-dropdown-menu li.nav-item");
const debounce = (func, delay) => {
let debounceTimer;
return (...args) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => func.apply(this, args), delay);
};
};
const changeTypesVisibility = () => {
$typeDropdownItems.each(function () {
const item = $(this);
item.toggle(
selectedService === "no service" ||
item.data("context") === "multisite",
);
});
};
$("#select-service").on("click", () => $serviceSearch.focus());
$serviceSearch.on(
"input",
debounce((e) => {
const inputValue = e.target.value.toLowerCase();
let visibleItems = 0;
$serviceDropdownItems.each(function () {
const item = $(this);
const matches = item.text().toLowerCase().includes(inputValue);
item.toggle(matches);
if (matches) {
visibleItems++; // Increment when an item is shown
}
});
if (visibleItems === 0) {
if ($serviceDropdownMenu.find(".no-service-items").length === 0) {
$serviceDropdownMenu.append(
'<li class="no-service-items dropdown-item text-muted">No Item</li>',
);
}
} else {
$serviceDropdownMenu.find(".no-service-items").remove();
}
}, 50),
);
$(document).on("hidden.bs.dropdown", "#select-service", function () {
$("#service-search").val("").trigger("input");
});
$serviceDropdownItems.on("click", function () {
selectedService = $(this).text().trim();
changeTypesVisibility();
if (
selectedService !== "no service" &&
$(`#config-type-${selectedType}`).data("context") !== "multisite"
) {
const firstMultisiteType = $(
`#types-dropdown-menu li.nav-item[data-context="multisite"]`,
).first();
$("#select-type")
.parent()
.attr(
"data-bs-original-title",
`Switched to ${firstMultisiteType
.text()
.trim()} as ${selectedType} is not a valid multisite type.`,
)
.tooltip("show");
// Hide tooltip after 2 seconds
setTimeout(() => {
$("#select-type")
.parent()
.tooltip("hide")
.attr("data-bs-original-title", "");
}, 2000);
firstMultisiteType.find("button").tab("show");
}
});
$typeDropdownItems.on("click", function () {
selectedType = $(this).text().trim();
if (selectedType.startsWith("CRS") || selectedType.startsWith("MODSEC")) {
editor.session.setMode("ace/mode/text"); // TODO: Support ModSecurity
} else {
editor.session.setMode("ace/mode/nginx");
}
});
$(".save-config").on("click", function () {
const value = editor.getValue().trim();
if (
value &&
value === initialContent &&
selectedService === originalService &&
selectedType === originalType &&
$("#config-name").val().trim() === originalName
) {
alert("No changes detected.");
return;
}
const $configInput = $("#config-name");
const configName = $configInput.val().trim();
const pattern = $configInput.attr("pattern");
let errorMessage = "";
let isValid = true;
if (!configName) {
errorMessage = "A custom configuration name is required.";
isValid = false;
} else if (pattern && !new RegExp(pattern).test(configName))
isValid = false;
if (!isValid) {
$configInput
.attr(
"data-bs-original-title",
errorMessage || "Please enter a valid configuration name.",
)
.tooltip("show");
// Hide tooltip after 2 seconds
setTimeout(() => {
$configInput.tooltip("hide").attr("data-bs-original-title", "");
}, 2000);
return;
}
console.log("Saving configuration...");
console.log("Service:", selectedService);
console.log("Type:", selectedType);
console.log("Name:", configName);
console.log("Value:", value);
const form = $("<form>", {
method: "POST",
action: window.location.href,
class: "visually-hidden",
});
form.append(
$("<input>", {
type: "hidden",
name: "service",
value: $("<div>").text(selectedService).html(),
}),
);
form.append(
$("<input>", {
type: "hidden",
name: "type",
value: $("<div>").text(selectedType).html(),
}),
);
form.append(
$("<input>", {
type: "hidden",
name: "name",
value: $("<div>").text(configName).html(),
}),
);
form.append(
$("<input>", {
type: "hidden",
name: "value",
value: $("<div>").text(value).html(),
}),
);
form.append(
$("<input>", {
type: "hidden",
name: "csrf_token",
value: $("<div>").text($("#csrf_token").val()).html(), // Sanitize the value
}),
);
$(window).off("beforeunload");
form.appendTo("body").submit();
});
changeTypesVisibility();
$(window).on("beforeunload", function (e) {
const value = editor.getValue().trim();
if (
value &&
value === initialContent &&
selectedService === originalService &&
selectedType === originalType &&
$("#config-name").val().trim() === originalName
)
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
});
});

View file

@ -0,0 +1,316 @@
$(document).ready(function () {
var actionLock = false;
const configNumber = parseInt($("#configs_number").val());
const setupDeletionModal = (configs) => {
const delete_modal = $("#modal-delete-configs");
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100">
<li class="list-group-item align-items-center bg-secondary text-white" style="flex: 1 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">Name</div>
</div>
</li>
<li class="list-group-item align-items-center bg-secondary text-white" style="flex: 1 1 0;">
<div class="fw-bold">Type</div>
</li>
<li class="list-group-item align-items-center bg-secondary text-white" style="flex: 1 1 0;">
<div class="fw-bold">Service</div>
</li>
</ul>`,
);
$("#selected-configs-delete").append(list);
configs.forEach((config) => {
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100"></ul>`,
);
// Create the list item using template literals
const listItem =
$(`<li class="list-group-item align-items-center text-center" style="flex: 1 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">${config.name}</div>
</div>
</li>`);
list.append(listItem);
const id = `${config.type.toLowerCase()}-${config.service.replaceAll(
".",
"_",
)}-${config.name}`;
// Clone the type element and append it to the list item
const typeClone = $(`#type-${id}`).clone();
const typeListItem = $(
`<li class="list-group-item d-flex align-items-center justify-content-center" style="flex: 1 1 0;"></li>`,
);
typeListItem.append(typeClone.removeClass("highlight"));
list.append(typeListItem);
// Clone the service element and append it to the list item
const serviceClone = $(`#service-${id}`).clone();
const serviceListItem = $(
`<li class="list-group-item d-flex align-items-center justify-content-center" style="flex: 1 1 0;"></li>`,
);
serviceListItem.append(serviceClone.removeClass("highlight"));
list.append(serviceListItem);
serviceClone.find('[data-bs-toggle="tooltip"]').tooltip();
$("#selected-configs-delete").append(list);
});
const modal = new bootstrap.Modal(delete_modal);
delete_modal
.find(".alert")
.text(
`Are you sure you want to delete the selected custom configuration${"s".repeat(
configs.length > 1,
)}?`,
);
modal.show();
configs.forEach((config) => {
if (config.service === "global") {
config.service = null;
}
});
$("#selected-configs-input-delete").val(JSON.stringify(configs));
};
const layout = {
topStart: {},
bottomEnd: {},
};
if (configNumber > 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)):not(:last-child)",
text: '<span class="tf-icons bx bx-columns bx-18px me-2"></span>Columns',
className: "btn btn-sm btn-outline-primary",
columnText: function (dt, idx, title) {
return idx + 1 + ". " + title;
},
},
{
extend: "colvisRestore",
text: '<span class="tf-icons bx bx-reset bx-18px me-2"></span>Reset<span class="d-none d-md-inline"> columns</span>',
className: "btn btn-sm btn-outline-primary",
},
{
extend: "collection",
text: '<span class="tf-icons bx bx-export bx-18px me-2"></span>Export',
className: "btn btn-sm btn-outline-primary",
buttons: [
{
extend: "copy",
text: '<span class="tf-icons bx bx-copy bx-18px me-2"></span>Copy current page',
exportOptions: {
modifier: {
page: "current",
},
},
},
{
extend: "csv",
text: '<span class="tf-icons bx bx-table bx-18px me-2"></span>CSV',
bom: true,
filename: "bw_custom_configs",
exportOptions: {
modifier: {
search: "none",
},
},
},
{
extend: "excel",
text: '<span class="tf-icons bx bx-table bx-18px me-2"></span>Excel',
filename: "bw_custom_configs",
exportOptions: {
modifier: {
search: "none",
},
},
},
],
},
{
extend: "collection",
text: '<span class="tf-icons bx bx-play bx-18px me-2"></span>Actions',
className: "btn btn-sm btn-outline-primary",
buttons: [
{
extend: "delete_configs",
className: "text-danger",
},
],
},
{
extend: "create_config",
},
];
$(document).on("hidden.bs.toast", ".toast", function (event) {
if (event.target.id.startsWith("feedback-toast")) {
setTimeout(() => {
$(this).remove();
}, 100);
}
});
$("#modal-delete-configs").on("hidden.bs.modal", function () {
$("#selected-configs-delete").empty();
$("#selected-configs-input-delete").val("");
});
const getSelectedConfigs = () => {
const configs = [];
$("tr.selected").each(function () {
const $this = $(this);
const name = $this.find("td:eq(1)").find("a").text().trim();
const type = $this.find("td:eq(2)").text().trim();
let service = $this.find("td:eq(4)");
if (service.find("a").length > 0) {
service = service.find("a").text().trim();
} else {
service = service.text().trim();
}
configs.push({ name: name, type: type, service: service });
});
return configs;
};
$.fn.dataTable.ext.buttons.create_config = {
text: '<span class="tf-icons bx bx-plus-circle bx-18px me-2"></span>Create<span class="d-none d-md-inline"> new custom config</span>',
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.delete_configs = {
text: '<span class="tf-icons bx bx-trash bx-18px me-2"></span>Delete',
action: function (e, dt, node, config) {
if (actionLock) {
return;
}
actionLock = true;
$(".dt-button-background").click();
const configs = getSelectedConfigs();
if (configs.length === 0) {
actionLock = false;
return;
}
setupDeletionModal(configs);
actionLock = false;
},
};
const configs_table = new DataTable("#configs", {
columnDefs: [
{
orderable: false,
render: DataTable.render.select(),
targets: 0,
},
{
orderable: false,
targets: -1,
},
{
visible: false,
targets: 6,
},
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
$(td).addClass("text-center align-items-center"); // Apply 'text-center' class to <td>
},
},
],
order: [[1, "desc"]],
autoFill: false,
responsive: true,
select: {
style: "multi+shift",
selector: "td:first-child",
headerCheckbox: false,
},
layout: layout,
language: {
info: "Showing _START_ to _END_ of _TOTAL_ custom configs",
infoEmpty: "No custom configs available",
infoFiltered: "(filtered from _MAX_ total custom configs)",
lengthMenu: "Display _MENU_ custom configs",
zeroRecords: "No matching custom configs found",
select: {
rows: {
_: "Selected %d custom configs",
0: "No custom configs selected",
1: "Selected 1 custom config",
},
},
},
initComplete: function (settings, json) {
$("#configs_wrapper .btn-secondary").removeClass("btn-secondary");
},
});
configs_table.on("mouseenter", "td", function () {
const rowIdx = configs_table.cell(this).index().row;
configs_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
configs_table
.cells()
.nodes()
.each(function (el) {
if (configs_table.cell(el).index().row === rowIdx)
el.classList.add("highlight");
});
});
configs_table.on("mouseleave", "td", function () {
configs_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
configs_table.rows({ page: "current" }).select();
} else {
// Deselect all rows on the current page
configs_table.rows({ page: "current" }).deselect();
}
});
$(".delete-config").on("click", function () {
const config = {
name: $(this).data("config-name"),
type: $(this).data("config-type"),
service: $(this).data("config-service"),
};
console.log(config);
setupDeletionModal([config]);
});
});

View file

@ -3,6 +3,92 @@ $(document).ready(function () {
var actionLock = false;
const instanceNumber = parseInt($("#instances_number").val());
const pingInstances = (instances) => {
setTimeout(() => {
if (actionLock) {
$(".dt-button-background").click();
$("#loadingModal").modal("show");
setTimeout(() => {
$("#loadingModal").modal("hide");
}, 5300);
}
}, 500);
// Create a FormData object
const formData = new FormData();
formData.append("csrf_token", $("#csrf_token").val()); // Add the CSRF token // TODO: find a way to ignore 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");
},
});
};
const execForm = (instances, action) => {
// 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();
};
const layout = {
topStart: {},
bottomEnd: {},
@ -142,61 +228,7 @@ $(document).ready(function () {
return;
}
setTimeout(() => {
if (actionLock) {
$(".dt-button-background").click();
$("#loadingModal").modal("show");
setTimeout(() => {
$("#loadingModal").modal("hide");
}, 5300);
}
}, 500);
// Create a FormData object
const formData = new FormData();
formData.append("csrf_token", $("#csrf_token").val()); // Add the CSRF token // TODO: find a way to ignore 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");
},
});
pingInstances(instances);
},
};
@ -223,31 +255,7 @@ $(document).ready(function () {
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();
execForm(instances, action);
},
};
@ -268,22 +276,45 @@ $(document).ready(function () {
$("#selected-instances-input").val(instances.join(","));
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100">
<li class="list-group-item align-items-center bg-secondary text-white" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">Hostname</div>
</div>
</li>
<li class="list-group-item align-items-center bg-secondary text-white" style="flex: 1 0;">
<div class="fw-bold">Health</div>
</li>
</ul>`,
);
$("#selected-instances").append(list);
const delete_modal = $("#modal-delete-instances");
instances.forEach((instance) => {
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100"></ul>`,
);
// Create the list item using template literals
const listItem =
$(`<li class="list-group-item d-flex justify-content-between align-items-center">
$(`<li class="list-group-item align-items-center text-center" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">${instance}</div>
</div>
</li>`);
list.append(listItem);
// Clone the status element and append it to the list item
const statusClone = $("#status-" + instance).clone();
listItem.append(statusClone);
const statusListItem = $(
`<li class="list-group-item d-flex align-items-center justify-content-center" style="flex: 1 0;"></li>`,
);
statusListItem.append(statusClone.removeClass("highlight"));
list.append(statusListItem);
// Append the list item to the list
$("#selected-instances").append(listItem);
$("#selected-instances").append(list);
});
const modal = new bootstrap.Modal(delete_modal);
@ -300,6 +331,14 @@ $(document).ready(function () {
render: DataTable.render.select(),
targets: 0,
},
{
orderable: false,
targets: -1,
},
{
visible: false,
targets: [2, 3],
},
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
@ -371,4 +410,20 @@ $(document).ready(function () {
instances_table.rows({ page: "current" }).deselect();
}
});
$(".ping-instance").on("click", function () {
if (actionLock) {
return;
}
actionLock = true;
const instance = $(this).data("instance");
pingInstances([instance]);
});
$(".reload-instance, .stop-instance").on("click", function () {
const instance = $(this).data("instance");
const action = $(this).hasClass("reload-instance") ? "reload" : "stop";
execForm([instance], action);
});
});

View file

@ -3,6 +3,86 @@ $(document).ready(function () {
var actionLock = false;
const serviceNumber = parseInt($("#services_number").val());
const setupModal = (services, modal) => {
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100">
<li class="list-group-item align-items-center bg-secondary text-white" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">Service name</div>
</div>
</li>
<li class="list-group-item align-items-center bg-secondary text-white" style="flex: 1 0;">
<div class="fw-bold">Type</div>
</li>
</ul>`,
);
modal.append(list);
services.forEach((service) => {
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100"></ul>`,
);
// Create the list item using template literals
const listItem =
$(`<li class="list-group-item align-items-center text-center" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">${service}</div>
</div>
</li>`);
list.append(listItem);
// Clone the type element and append it to the list item
const typeClone = $("#type-" + service.replace(/\./g, "-")).clone();
const typeListItem = $(
`<li class="list-group-item d-flex align-items-center justify-content-center" style="flex: 1 0;"></li>`,
);
typeListItem.append(typeClone.removeClass("highlight"));
list.append(typeListItem);
// Append the list item to the list
modal.append(list);
});
};
const setupConversionModal = (services, convertionType = "draft") => {
$("#selected-services-input-convert").val(services.join(","));
setupModal(services, $("#selected-services-convert"));
const convert_modal = $("#modal-convert-services");
convert_modal
.find(".alert")
.text(
`Are you sure you want to convert the selected service${"s".repeat(
services.length > 1,
)} 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();
};
const setupDeletionModal = (services) => {
$("#selected-services-input-delete").val(services.join(","));
setupModal(services, $("#selected-services-delete"));
const convert_modal = $("#modal-delete-services");
const modal = new bootstrap.Modal(convert_modal);
convert_modal
.find(".alert")
.text(
`Are you sure you want to delete the selected service${"s".repeat(
services.length > 1,
)}?`,
);
modal.show();
};
const layout = {
topStart: {},
bottomEnd: {},
@ -18,7 +98,7 @@ $(document).ready(function () {
layout.topStart.buttons = [
{
extend: "colvis",
columns: "th:not(:first-child):not(:nth-child(2))",
columns: "th:not(:first-child):not(:nth-child(2)):not(:last-child)",
text: '<span class="tf-icons bx bx-columns bx-18px me-2"></span>Columns',
className: "btn btn-sm btn-outline-primary",
columnText: function (dt, idx, title) {
@ -72,9 +152,6 @@ $(document).ready(function () {
text: '<span class="tf-icons bx bx-play bx-18px me-2"></span>Actions',
className: "btn btn-sm btn-outline-primary",
buttons: [
{
extend: "clone_service",
},
{
extend: "convert_services",
text: '<span class="tf-icons bx bx-globe bx-18px me-2"></span>Convert to<span class="d-none d-md-inline"> online</span>',
@ -128,36 +205,6 @@ $(document).ready(function () {
},
};
$.fn.dataTable.ext.buttons.clone_service = {
text: '<span class="tf-icons bx bx-copy-alt bx-18px me-2"></span>Clone<span class="d-none d-md-inline"> service</span>',
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) {
@ -192,44 +239,7 @@ $(document).ready(function () {
return;
}
$("#selected-services-input-convert").val(services.join(","));
services.forEach((service) => {
// 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">${service}</div>
</div>
</li>`);
// 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();
setupConversionModal(filteredServices, convertionType);
actionLock = false;
},
@ -250,28 +260,7 @@ $(document).ready(function () {
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 =
$(`<li class="list-group-item d-flex justify-content-between align-items-center">
<div class="ms-2 me-auto">
<div class="fw-bold">${service}</div>
</div>
</li>`);
// 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();
setupDeletionModal(services);
actionLock = false;
},
@ -284,6 +273,10 @@ $(document).ready(function () {
render: DataTable.render.select(),
targets: 0,
},
{
orderable: false,
targets: -1,
},
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
@ -355,4 +348,15 @@ $(document).ready(function () {
services_table.rows({ page: "current" }).deselect();
}
});
$(".convert-service").on("click", function () {
const service = $(this).data("service-id");
const convertionType = $(this).data("value");
setupConversionModal([service], convertionType);
});
$(".delete-service").on("click", function () {
const service = $(this).data("service-id");
setupDeletionModal([service]);
});
});

View file

@ -2,7 +2,13 @@ $(document).ready(() => {
var toastNum = 0;
let currentPlugin = "general";
let currentStep = 1;
let usedTemplate = $("#used-template").val().trim();
const $templateInput = $("#used-template");
let usedTemplate = "advanced";
if ($templateInput.length) {
usedTemplate = $templateInput.val().trim();
}
let currentTemplate = $("#selected-template").val();
let currentMode = $("#selected-mode").val();
let currentType = $("#selected-type").val();
@ -367,7 +373,7 @@ $(document).ready(() => {
$("#select-plugin").on("click", () => $pluginSearch.focus());
$("#plugin-search").on(
$pluginSearch.on(
"input",
debounce((e) => {
const inputValue = e.target.value.toLowerCase();
@ -575,7 +581,6 @@ $(document).ready(() => {
.find(".multiple-collapse")
.attr("id")
.replace(`${multipleId}-`, ""),
10,
);
})
.get()
@ -668,7 +673,8 @@ $(document).ready(() => {
// Remove "add-multiple" button and append the "REMOVE" button
multipleClone.find(".add-multiple").remove();
multipleClone.find(".show-multiple").before(`
const multipleShow = multipleClone.find(".show-multiple");
multipleShow.before(`
<div>
<button id="remove-${cloneId}" type="button" class="btn btn-xs btn-text-danger rounded-pill remove-multiple p-0 pe-2">
<i class="bx bx-trash bx-sm"></i>&nbsp;REMOVE
@ -684,7 +690,6 @@ $(document).ready(() => {
.find(".multiple-collapse")
.attr("id")
.replace(`${multipleId}-`, ""),
10,
);
if (containerSuffix > suffix) {
$(this).before(multipleClone); // Insert before the first container with a higher suffix
@ -693,6 +698,9 @@ $(document).ready(() => {
}
});
multipleShow.html(`<i class="bx bx-hide bx-sm"></i>&nbsp;SHOW`);
multipleClone.find(".multiple-collapse").collapse("hide");
if (!inserted) {
// If no higher suffix was found, append to the end
$(`#${multipleId}`).append(multipleClone);
@ -707,9 +715,10 @@ $(document).ready(() => {
.attr("data-bs-target", `#${cloneId}`)
.attr("aria-controls", cloneId);
if (showMultiple.text().trim() === "SHOW") showMultiple.trigger("click");
highlightSettings(multipleClone);
setTimeout(() => {
showMultiple.trigger("click");
highlightSettings(multipleClone);
}, 50);
});
$(document).on("click", ".remove-multiple", function () {
@ -758,17 +767,11 @@ $(document).ready(() => {
const currentStepContainer = $(`#${currentStepId}`);
const isStepValid = validateCurrentStepInputs(currentStepContainer);
if (!isStepValid) return;
} else {
let minSettings = 4;
if (!form.find("input[name='IS_DRAFT']").length) minSettings = 2;
const draftInput = $("#is-draft");
} else if (currentMode === "raw") {
const wasDraft = draftInput.data("original") === "yes";
let isDraft = draftInput.val() === "yes";
if (currentMode === "raw")
isDraft = form.find("input[name='IS_DRAFT']").val() === "yes";
isDraft = form.find("input[name='IS_DRAFT']").val() === "yes";
if (form.children().length < minSettings && isDraft === wasDraft) {
if (form.children().length < 2 && isDraft === wasDraft) {
alert("No changes detected.");
return;
}

View file

@ -25,7 +25,7 @@
<link rel="stylesheet"
href="{{ url_for('static', filename='fonts/boxicons.min.css') }}"
nonce="{{ style_nonce }}" />
{% if current_endpoint in ("instances", "services") %}
{% if current_endpoint in ("instances", "services", "configs", "cache") %}
<!-- Datatables -->
<link rel="stylesheet"
href="{{ url_for('static', filename='libs/datatables/datatables.min.css') }}"
@ -89,11 +89,11 @@
nonce="{{ script_nonce }}"></script>
<script src="{{ url_for('static', filename='libs/purify/purify.min.js') }}"
nonce="{{ script_nonce }}"></script>
{% if current_endpoint in ("instances", "services") %}
{% if current_endpoint in ("instances", "services", "configs", "cache") %}
<script src="{{ url_for('static', filename='libs/datatables/datatables.min.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
{% if current_endpoint != "services" and "services" in request.path %}
{% if current_endpoint not in ("services", "configs", "cache") and ("services" in request.path or "configs" in request.path or "cache" in request.path) %}
<script src="{{ url_for('static', filename='libs/ace/src-min/ace.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
@ -131,6 +131,18 @@
{% elif current_endpoint == "services" %}
<script src="{{ url_for('static', filename='js/pages/services.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint == "configs" %}
<script src="{{ url_for('static', filename='js/pages/configs.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint != "configs" and "configs" in request.path %}
<script src="{{ url_for('static', filename='js/pages/config_edit.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint == "cache" %}
<script src="{{ url_for('static', filename='js/pages/cache.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint != "cache" and "cache" in request.path %}
<script src="{{ url_for('static', filename='js/pages/cache_view.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
<script async defer src="{{ url_for('static', filename='js/buttons.js') }}"></script>
</body>

View file

@ -0,0 +1,90 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden" id="cache_number" value="{{ caches|length }}" />
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<table id="cache" class="table w-100">
<thead>
<tr>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The user defined Cache's file name">File name</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Cache's job name">Job name</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Cache's job's Plugin">Plugin</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Service associated with the Cache">Service</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The date and time when the Cache was last updated">Last Update</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Cache's checksum">Checksum</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The actions that can be performed on the Instance">Actions</th>
</tr>
</thead>
<tbody>
{% for cache in caches %}
{% set service_id = cache['service_id'] if cache['service_id'] else 'global' %}
<tr>
<td>
<a href="{{ url_for("cache") }}/{{ service_id }}/{{ cache['plugin_id'] }}/{{ cache['job_name'] }}/{{ cache['file_name'].replace('/', '_') }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="View {{ cache['file_name'] }}"><i class="bx bx-show bx-xs"></i>&nbsp;{{ cache['file_name'] }}</a>
</td>
<td>{{ cache['job_name'] }}</td>
<td>{{ cache['plugin_id'] }}</td>
<td id="service-{{ cache['job_name'] }}-{{ service_id.replace('.', '_') }}-{{ cache['file_name'].replace('.', '_') }}">
{% if cache["service_id"] %}
<a href="{{ url_for("services") }}/{{ cache['service_id'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Edit service {{ cache['service_id'] }}"><i class="bx bx-edit bx-xs"></i>&nbsp;{{ cache["service_id"] }}</a>
{% else %}
<span class="badge rounded-pill bg-label-secondary">global</span>
{% endif %}
</td>
<td>
{{ cache["last_update"] if cache["last_update"] == "Never" else cache["last_update"].astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }}
</td>
<td>{{ cache["checksum"] }}</td>
<td>
<div class="d-flex justify-content-center">
<a role="button"
class="btn btn-primary btn-sm me-1"
href="{{ url_for("cache") }}/{{ service_id }}/{{ cache['plugin_id'] }}/{{ cache['job_name'] }}/{{ cache['file_name'].replace('/', '_') }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="View {{ cache['file_name'] }}">
<i class="bx bx-show bx-xs"></i>
</a>
<a role="button"
class="btn btn-outline-secondary btn-sm me-1"
href="{{ url_for("cache") }}/{{ service_id }}/{{ cache['plugin_id'] }}/{{ cache['job_name'] }}/{{ cache['file_name'].replace('/', '_') }}?download=true"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Download {{ cache['file_name'] }}"
target="_blank"
rel="noopener noreferrer">
<i class="bx bx-download bx-xs"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -0,0 +1,24 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card position-relative p-4 min-vh-70">
<div class="position-absolute top-0 end-0 m-2" style="z-index: 1000">
<div class="card p-1 me-2">
<a role="button"
class="btn btn-sm btn-outline-secondary "
href="{{ url_for("cache") }}/{{ '/'.join(request.path.split('/')[2:]) }}?download=true"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Download file"
target="_blank"
rel="noopener noreferrer">
<i class="bx bx-download bx-xs"></i>
<span class="d-none d-md-inline">&nbsp;Download</span>
</a>
</div>
</div>
<div id="cache-value"
class="ace-editor border rounded position-absolute top-0 start-0 end-0 bottom-0">{{ cache_file }}</div>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -0,0 +1,142 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<input type="hidden"
id="selected-service"
name="selected_service"
value="{{ config_service if config_service and config_service != "global" else "no service" }}">
<input type="hidden"
id="selected-type"
name="selected_type"
value="{{ type if type else "HTTP" }}">
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}">
<div class="card p-1 mb-4 sticky-card">
<div class="d-flex flex-wrap justify-content-around align-items-center">
<div class="d-flex">
{% if not config_template and config_method and config_method != "ui" %}
<button type="button" class="btn btn-sm btn-secondary ms-2 disabled">
<i class="bx bx-xs bx-cube"></i>
&nbsp;Service: {{ config_service or "no service" }}
</button>
<button type="button" class="btn btn-sm btn-secondary ms-2 disabled">
<i class="bx bx-xs bx-window"></i>
&nbsp;Config Type: {{ type }}
</button>
{% else %}
<div class="dropdown btn-group me-2">
<button id="select-service"
type="button"
class="btn btn-outline-primary dropdown-toggle"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
<i class="bx bx-cube"></i>
<span class="d-none d-md-inline">&nbsp;Service</span>
</button>
<ul id="services-dropdown-menu"
class="dropdown-menu nav-pills max-vh-60 overflow-auto pt-0"
role="tablist">
<div class="input-group input-group-merge mb-2">
<span class="input-group-text p-2 border-0 border-primary border-bottom shadow-none"><i class="bx fs-6 bx-search"></i></span>
<input id="service-search"
type="text"
class="form-control border-0 border-primary border-bottom shadow-none"
placeholder="Search..."
aria-label="Search...">
</div>
<li class="nav-item">
<button type="button"
class="dropdown-item{% if not config_service or config_service == 'global' %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
{% if not config_service %}aria-selected="true"{% endif %}>no service</button>
</li>
{% for service in services %}
<li class="nav-item">
<button type="button"
class="dropdown-item{% if config_service == service %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
{% if config_service == service %}aria-selected="true"{% endif %}>
{{ service }}
</button>
</li>
{% endfor %}
</ul>
</div>
<div class="dropdown btn-group">
<button id="select-type"
type="button"
class="btn btn-outline-primary dropdown-toggle"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
<i class="bx bx-window"></i>
<span class="d-none d-md-inline">&nbsp;Config Type</span>
</button>
<ul id="types-dropdown-menu"
class="dropdown-menu nav-pills max-vh-60 overflow-auto pt-0"
role="tablist">
{% for config_type, data in config_types.items() %}
<li id="config-type-{{ config_type }}"
class="nav-item"
data-context="{{ data['context'] }}"
data-bs-toggle="tooltip"
data-bs-placement="top"
data-bs-original-title="{{ data['description'] }}">
<button type="button"
class="dropdown-item{% if config_type == type or not type and loop.index == 1 %} active{% endif %}"
role="tab"
data-bs-toggle="tab"
{% if config_type == type or not type and loop.index == 1 %}aria-selected="true"{% endif %}>
<i class="bx bx-{% if config_type.startswith('CRS') %}shield-alt{% elif config_type == 'MODSEC_CRS' %}shield-quarter{% elif config_type == 'MODSEC' %}shield-alt-2{% elif 'STREAM' in config_type %}network-chart{% else %}window-alt{% endif %}"></i>&nbsp;{{ config_type }}
</button>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if config_template %}
<button type="button" class="btn btn-sm btn-secondary ms-2 disabled">
<i class="bx bx-xs bx-spreadsheet"></i>
&nbsp;From template: {{ config_template }}
</button>
{% endif %}
</div>
<div class="row align-items-end">
<div class="col form-floating input-group input-group-merge shadow-none">
<input id="config-name"
type="text"
class="form-control form-control-sm border-0 border-primary border-bottom shadow-none"
placeholder="Configuration Name"
value="{{ name }}"
pattern="^[a-zA-Z0-9_\-]{1,64}$"
required
{% if not config_template and config_method and config_method != "ui" %}disabled{% endif %}>
<label for="config-name">Configuration Name</label>
<span class="input-group-text border-0 border-primary border-bottom mt-2 pb-0 shadow-none"
id="config-name-suffix">.conf</span>
</div>
</div>
<div {% if not config_template and config_method and config_method != "ui" %}data-bs-toggle="tooltip" data-bs-placement="top" title="The custom config was created using the {{ config_method }} method, therefore it is locked"{% endif %}>
<button type="button"
class="btn btn-sm btn-outline-bw-green save-config{% if not config_template and config_method and config_method != "ui" %} disabled{% endif %}">
<i class="bx bx-save bx-sm"></i>
<span class="d-none d-md-inline">&nbsp;Save</span>
</button>
</div>
</div>
</div>
<div class="card position-relative p-4 min-vh-70"
{% if not config_template and config_method and config_method != "ui" %}data-bs-toggle="tooltip" data-bs-placement="top" title="Disabled by {{ config_method }}"{% endif %}>
<div id="config-value"
data-language="{% if type and type.startswith(('CRS', 'MODSEC')) %}ModSecurity{% else %}NGINX{% endif %}"
data-method="{{ config_method }}"
data-template="{{ config_template }}"
class="ace-editor border rounded position-absolute top-0 start-0 end-0 bottom-0">{{ config_value }}</div>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -0,0 +1,151 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden" id="configs_number" value="{{ configs|length }}" />
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<table id="configs" class="table w-100">
<thead>
<tr>
<th>
<input id="select-all-rows"
aria-label="Select all rows"
class="dt-select-checkbox mb-1"
type="checkbox">
Select All
</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The user defined Custom config's name">Name</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Custom config's type">Type</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The creation method of the Custom config">Method</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Service associated with the Custom configuration">Service</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Indication if the custom config is from a template or not">
Template
</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Custom config's checksum">Checksum</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The actions that can be performed on the Instance">Actions</th>
</tr>
</thead>
<tbody>
{% for config in configs %}
{% set service_id = config['service_id'] if config['service_id'] else 'global' %}
<tr>
<td></td>
<td>
<a href="{{ url_for("configs") }}/{{ service_id }}/{{ config['type'] }}/{{ config['name'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Edit custom config {{ config['name'] }}"><i class="bx bx-edit bx-xs"></i>&nbsp;{{ config["name"] }}</a>
</td>
<td id="type-{{ config['type'] }}-{{ service_id.replace('.', '_') }}-{{ config['name'] }}">
<i class="bx bx-{% if config['type'].startswith('crs') %}shield-alt{% elif config['type'] == 'modsec_crs' %}shield-quarter{% elif config['type'] == 'modsec' %}shield-alt-2{% elif 'stream' in config['type'] %}network-chart{% else %}window-alt{% endif %}"></i>
{{ config["type"]|upper }}
</td>
<td>{{ config["method"] }}</td>
<td id="service-{{ config['type'] }}-{{ service_id.replace('.', '_') }}-{{ config['name'] }}">
{% if config["service_id"] %}
<a href="{{ url_for("services") }}/{{ config['service_id'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Edit service {{ config['service_id'] }}"><i class="bx bx-edit bx-xs"></i>&nbsp;{{ config["service_id"] }}</a>
{% else %}
<span class="badge rounded-pill bg-label-secondary">global</span>
{% endif %}
</td>
<td>
{% if config["template"] %}
<span class="badge rounded-pill bg-label-primary">{{ config["template"] }}</span>
{% else %}
<span class="badge rounded-pill bg-label-secondary">no template</span>
{% endif %}
</td>
<td>{{ config["checksum"] }}</td>
<td>
<div class="d-flex justify-content-center">
<a role="button"
class="btn btn-primary btn-sm me-1"
href="{{ url_for("configs") }}/{{ service_id }}/{{ config['type'] }}/{{ config['name'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Edit custom config {{ config['name'] }}">
<i class="bx bx-edit bx-xs"></i>
</a>
<a role="button"
class="btn btn-outline-secondary btn-sm me-1"
href="{{ url_for("configs") }}/new?clone={{ service_id }}/{{ config['type'] }}/{{ config['name'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Clone custom config {{ config['name'] }}">
<i class="bx bx-copy-alt bx-xs"></i>
</a>
<div data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="{% if config['method'] != 'ui' %}Disabled by {% if config['template'] %}template: {{ config['template'] }}{% else %}{{ config['method'] }}{% endif %}{% else %}Delete custom config {{ config['name'] }}{% endif %}">
<button type="button"
data-config-name="{{ config['name'] }}"
data-config-type="{{ config['type'] }}"
data-config-service="{{ config['service_id'] or 'global' }}"
class="btn btn-outline-danger btn-sm me-1 delete-config{% if config['method'] != 'ui' %} disabled{% endif %}">
<i class="bx bx-trash bx-xs"></i>
</button>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="modal modal-lg fade"
id="modal-delete-configs"
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">Confirm deletion</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<form action="{{ url_for("configs") }}/delete" method="POST">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden"
id="selected-configs-input-delete"
name="configs"
value="" />
<div class="alert alert-danger text-center" role="alert">Are you sure you want to delete the selected configs?</div>
<div id="selected-configs-delete" class="mb-3"></div>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-danger me-2">Delete</button>
<button type="reset"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -54,6 +54,9 @@
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The date and time when the Instance was last seen">Last Seen</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The actions that can be performed on the Instance">Actions</th>
</tr>
</thead>
<tbody>
@ -86,6 +89,45 @@
</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>
<div class="d-flex justify-content-center">
<button type="button"
class="btn btn-primary btn-sm me-1 ping-instance"
data-instance="{{ instance['hostname'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Ping Instance {{ instance['hostname'] }}">
<i class="bx bx-bell bx-xs"></i>
</button>
<div data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="{% if instance.status != 'up' %}Instance {{ instance['hostname'] }} is not up{% else %}Reload instance {{ instance['hostname'] }}{% endif %}">
<button type="button"
class="btn btn-outline-secondary btn-sm me-1 reload-instance{% if instance.status != 'up' %} disabled{% endif %}"
data-instance="{{ instance['hostname'] }}">
<i class="bx bx-refresh bx-xs"></i>
</button>
</div>
<div data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="{% if instance.status != 'up' %}Instance {{ instance['hostname'] }} is not up{% else %}Stop instance {{ instance['hostname'] }}{% endif %}">
<button type="button"
class="btn btn-outline-secondary btn-sm me-1 stop-instance{% if instance.status != 'up' %} disabled{% endif %}"
data-instance="{{ instance['hostname'] }}">
<i class="bx bx-stop bx-xs"></i>
</button>
</div>
<div data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="{% if instance['method'] != 'ui' %}Disabled by {{ instance['method'] }}{% else %}Delete instance {{ instance['hostname'] }}{% endif %}">
<button type="button"
data-instance="{{ instance['hostname'] }}"
class="btn btn-outline-danger btn-sm me-1 delete-instance{% if instance['method'] != 'ui' %} disabled{% endif %}">
<i class="bx bx-trash bx-xs"></i>
</button>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
@ -180,8 +222,7 @@
<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 id="selected-instances" class="mb-3"></div>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-danger me-2">Delete instances</button>

View file

@ -31,7 +31,7 @@
} %}
<ul class="menu-inner py-1">
{% for endpoint, item in menu_items.items() %}
<li class="menu-item {% if endpoint in request.path %}active{% endif %} {% if item.get('sub') and item.get('open', True) %}open{% endif %}">
<li class="menu-item {% if endpoint in request.path.split('/')[1] %}active{% endif %} {% if item.get('sub') and item.get('open', True) %}open{% endif %}">
<a href="{{ item['url'] }}"
class="menu-link {% if item.get('sub') %}menu-toggle{% endif %}">
<i class="menu-icon tf-icons bx {{ item['icon'] }}"></i>

View file

@ -224,8 +224,7 @@
{% for multiple, multiples in plugin_multiples.items() %}
{% set multiple_settings = settings|length > 1 %}
<div id="multiple-{{ plugin }}-{{ multiple }}"
class="col-12{% if multiple_plugin_multiples %} col-md-6{% endif %}">
<!-- TODO: Handle if multiple_plugin_multiples|length > 2 and not multiple_settings -> col-lg-4 via JS -->
class="col-12{% if multiple_plugin_multiples %} col-md-6{% endif %}{% if plugin_multiples|length > 2 and not multiple_settings %} col-lg-4{% endif %}">
{% for setting_suffix, settings in multiples.items() %}
<div class="row multiple-container pt-2 pb-2">
{% set setting_id_suffix = "-" + setting_suffix %}

View file

@ -22,10 +22,10 @@
data-bs-original-title="The name of the service">Service name</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The creation method of the Service">Method</th>
data-bs-original-title="The type of the Service">Type</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The type of the Service">Type</th>
data-bs-original-title="The creation method of the Service">Method</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The date and time when the Service was created">Created</th>
@ -47,7 +47,6 @@
data-bs-placement="bottom"
data-bs-original-title="Edit service {{ service['id'] }}"><i class="bx bx-edit bx-xs"></i>&nbsp;{{ service["id"] }}</a>
</td>
<td id="method-{{ service['id'].replace('.', '-') }}">{{ service["method"] }}</td>
<td>
{% if service['is_draft'] %}
<span id="type-{{ service['id'].replace('.', '-') }}"
@ -59,28 +58,41 @@
class="badge rounded-pill bg-label-primary">Online</span>
{% endif %}
</td>
<td id="method-{{ service['id'].replace('.', '-') }}">{{ service["method"] }}</td>
<td>{{ service['creation_date'].astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }}</td>
<td>{{ service['last_update'].astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") }}</td>
<td>
<div class="d-flex justify-content-center">
<a role="button"
class="btn btn-primary btn-sm me-1"
href="{{ url_for("services") }}/{{ service['id'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Edit service {{ service['id'] }}">
class="btn btn-outline-primary btn-sm me-1"
href="https://{{ service['id'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Access service {{ service['id'] }}"
target="_blank"
rel="noopener noreferrer">
<i class="bx bx-link-external bx-xs"></i>
</a>
<a role="button"
class="btn btn-primary btn-sm me-1"
href="{{ url_for("services") }}/{{ service['id'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Edit service {{ service['id'] }}">
<i class="bx bx-edit bx-xs"></i>
</a>
<a role="button"
class="btn btn-outline-secondary btn-sm me-1"
href="{{ url_for("services") }}/new?clone={{ service['id'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Clone service {{ service['id'] }}">
class="btn btn-outline-secondary btn-sm me-1"
href="{{ url_for("services") }}/new?clone={{ service['id'] }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Clone service {{ service['id'] }}">
<i class="bx bx-copy-alt bx-xs"></i>
</a>
<button type="button"
class="btn btn-outline-secondary btn-sm me-1"
class="btn btn-outline-secondary btn-sm me-1 convert-service"
data-service-id="{{ service['id'] }}"
data-value="{{ 'online' if service['is_draft'] else 'draft' }}"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Convert service {{ service['id'] }} to {{ 'online' if service['is_draft'] else 'draft' }}">
@ -90,7 +102,8 @@
data-bs-placement="bottom"
data-bs-original-title="{% if service['method'] != 'ui' %}Disabled by {{ service['method'] }}{% else %}Delete service {{ service['id'] }}{% endif %}">
<button type="button"
class="btn btn-outline-danger btn-sm me-1{% if service['method'] != 'ui' %} disabled{% endif %}">
data-service-id="{{ service['id'] }}"
class="btn btn-outline-danger btn-sm me-1 delete-service{% if service['method'] != 'ui' %} disabled{% endif %}">
<i class="bx bx-trash bx-xs"></i>
</button>
</div>
@ -127,7 +140,7 @@
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Convert services to Draft</h5>
<h5 class="modal-title">Confirm Conversion</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
@ -142,11 +155,13 @@
name="services"
value="" />
<div class="alert alert-danger text-center" role="alert">Are you sure you want to convert the selected services?</div>
<ul id="selected-services-convert" class="list-group mb-3">
</ul>
<div id="selected-services-convert" class="mb-3"></div>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-secondary me-2">Convert services</button>
<button type="submit" class="btn btn-outline-success me-2">Convert services</button>
<button type="reset"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
@ -161,7 +176,7 @@
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Delete services</h5>
<h5 class="modal-title">Confirm deletion</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
@ -175,14 +190,13 @@
name="services"
value="" />
<div class="alert alert-danger text-center" role="alert">Are you sure you want to delete the selected services?</div>
<ul id="selected-services-delete" class="list-group mb-3">
</ul>
<div id="selected-services-delete" class="mb-3"></div>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-danger me-2">Delete services</button>
<button type="submit" class="btn btn-outline-danger me-2">Delete</button>
<button type="reset"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">Close</button>
data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>

View file

@ -229,8 +229,8 @@ def handle_csrf_error(_):
logout_user()
flash("Wrong CSRF token !", "error")
if not current_user:
return render_template("setup.html"), 403
return render_template("login.html"), 403
return redirect(url_for("setup.setup_page")), 403
return redirect(url_for("login.login_page")), 403
def update_latest_stable_release():