Add reports, bans, logs and jobs page to web UI

This commit is contained in:
Théophile Diot 2024-09-20 17:14:28 +02:00
parent b75a0fe5f5
commit c6d9846279
No known key found for this signature in database
GPG key ID: FA995104A0BA376A
37 changed files with 3021 additions and 342 deletions

View file

@ -2057,7 +2057,7 @@ class Database:
return custom_config
def upsert_custom_config(self, config_type: str, name: str, config: Dict[str, Any], *, service_id: Optional[str] = None) -> str:
def upsert_custom_config(self, config_type: str, name: str, config: Dict[str, Any], *, service_id: Optional[str] = None, new: bool = False) -> str:
"""Update or insert a custom config in the database"""
with self._db_session() as session:
if self.readonly:
@ -2087,6 +2087,8 @@ class Database:
)
)
else:
if new:
return "The custom config already exists"
custom_config.service_id = config.get("service_id")
custom_config.data = data
custom_config.checksum = checksum
@ -3181,8 +3183,8 @@ class Database:
"reload": job.reload,
"history": [
{
"start_date": job_run.start_date.strftime("%Y/%m/%d, %H:%M:%S %Z"),
"end_date": job_run.end_date.strftime("%Y/%m/%d, %H:%M:%S %Z"),
"start_date": job_run.start_date.isoformat(),
"end_date": job_run.end_date.isoformat(),
"success": job_run.success,
}
for job_run in session.query(Jobs_runs)

View file

@ -204,7 +204,6 @@ class InstancesUtils:
def get_instance_bans(instance: Instance) -> List[dict[str, Any]]:
resp, instance_bans = instance.bans()
if resp:
self.__db.logger.warning(resp)
return []
return instance_bans[instance.hostname].get("data", [])
@ -226,8 +225,7 @@ class InstancesUtils:
def get_instance_reports(instance: Instance) -> Tuple[bool, dict[str, Any]]:
resp, instance_reports = instance.reports()
if resp:
self.__db.logger.warning(resp)
if not resp:
return []
return (instance_reports[instance.hostname].get("msg") or {"requests": []}).get("requests", [])

View file

@ -1,123 +1,26 @@
from datetime import datetime
from json import dumps, loads as json_loads
from json import JSONDecodeError, dumps, loads
from math import floor
from time import time
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from redis import Redis, Sentinel
from app.dependencies import BW_CONFIG, BW_INSTANCES_UTILS, DB
from app.utils import LOGGER
from app.dependencies import BW_INSTANCES_UTILS
from app.routes.utils import get_remain, handle_error, verify_data_in_form
from app.routes.utils import get_redis_client, get_remain, handle_error, verify_data_in_form
bans = Blueprint("bans", __name__)
@bans.route("/bans", methods=["GET", "POST"])
@bans.route("/bans", methods=["GET"])
@login_required
def bans_page():
if request.method == "POST":
if DB.readonly:
return handle_error("Database is in read-only mode", "bans")
# Check variables
verify_data_in_form(data={"operation": ("ban", "unban")}, err_message="Invalid operation parameter on /bans.", redirect_url="bans")
verify_data_in_form(data={"data": None}, err_message="Missing data parameter on /bans.", redirect_url="bans")
redis_client = None
db_config = BW_CONFIG.get_config(
global_only=True,
methods=False,
filtered_settings=(
"USE_REDIS",
"REDIS_HOST",
"REDIS_PORT",
"REDIS_DB",
"REDIS_TIMEOUT",
"REDIS_KEEPALIVE_POOL",
"REDIS_SSL",
"REDIS_USERNAME",
"REDIS_PASSWORD",
"REDIS_SENTINEL_HOSTS",
"REDIS_SENTINEL_USERNAME",
"REDIS_SENTINEL_PASSWORD",
"REDIS_SENTINEL_MASTER",
),
)
use_redis = db_config.get("USE_REDIS", "no") == "yes"
redis_host = db_config.get("REDIS_HOST")
if use_redis and redis_host:
redis_port = db_config.get("REDIS_PORT", "6379")
if not redis_port.isdigit():
redis_port = "6379"
redis_port = int(redis_port)
redis_db = db_config.get("REDIS_DB", "0")
if not redis_db.isdigit():
redis_db = "0"
redis_db = int(redis_db)
redis_timeout = db_config.get("REDIS_TIMEOUT", "1000.0")
try:
redis_timeout = float(redis_timeout)
except ValueError:
redis_timeout = 1000.0
redis_keepalive_pool = db_config.get("REDIS_KEEPALIVE_POOL", "10")
if not redis_keepalive_pool.isdigit():
redis_keepalive_pool = "10"
redis_keepalive_pool = int(redis_keepalive_pool)
redis_ssl = db_config.get("REDIS_SSL", "no") == "yes"
username = db_config.get("REDIS_USERNAME", None) or None
password = db_config.get("REDIS_PASSWORD", None) or None
sentinel_hosts = db_config.get("REDIS_SENTINEL_HOSTS", [])
if isinstance(sentinel_hosts, str):
sentinel_hosts = [host.split(":") if ":" in host else (host, "26379") for host in sentinel_hosts.split(" ") if host]
if sentinel_hosts:
sentinel_username = db_config.get("REDIS_SENTINEL_USERNAME", None) or None
sentinel_password = db_config.get("REDIS_SENTINEL_PASSWORD", None) or None
sentinel_master = db_config.get("REDIS_SENTINEL_MASTER", "")
sentinel = Sentinel(
sentinel_hosts,
username=sentinel_username,
password=sentinel_password,
ssl=redis_ssl,
socket_timeout=redis_timeout,
socket_connect_timeout=redis_timeout,
socket_keepalive=True,
max_connections=redis_keepalive_pool,
)
redis_client = sentinel.slave_for(sentinel_master, db=redis_db, username=username, password=password)
else:
redis_client = Redis(
host=redis_host,
port=redis_port,
db=redis_db,
username=username,
password=password,
socket_timeout=redis_timeout,
socket_connect_timeout=redis_timeout,
socket_keepalive=True,
max_connections=redis_keepalive_pool,
ssl=redis_ssl,
)
try:
redis_client.ping()
except BaseException:
redis_client = None
flash("Couldn't connect to redis, ban list might be incomplete", "error")
redis_client = get_redis_client()
def get_load_data():
try:
data = json_loads(request.form["data"])
data = loads(request.form["data"])
assert isinstance(data, list)
return data
except BaseException:
@ -128,10 +31,9 @@ def bans_page():
for unban in data:
try:
unban = json_loads(unban.replace('"', '"').replace("'", '"'))
unban = loads(unban.replace('"', '"').replace("'", '"'))
except BaseException:
flash(f"Invalid unban: {unban}, skipping it ...", "error")
LOGGER.exception(f"Couldn't unban {unban['ip']}")
continue
if "ip" not in unban:
@ -190,7 +92,7 @@ def bans_page():
if not data:
continue
exp = redis_client.ttl(key)
bans.append({"ip": ip, "exp": exp} | json_loads(data)) # type: ignore
bans.append({"ip": ip, "exp": exp} | loads(data)) # type: ignore
instance_bans = BW_INSTANCES_UTILS.get_bans()
# Prepare data
@ -200,22 +102,96 @@ def bans_page():
if not any(b["ip"] == ban["ip"] for b in bans):
bans.append(ban)
# Get the last 100 bans
bans = bans[:100]
reasons = set()
remains = set()
for ban in bans:
exp = ban.pop("exp", 0)
# Add remain
remain = ("unknown", "unknown") if exp <= 0 else get_remain(exp)
ban["remain"] = remain[0]
remains.add(remain[1])
# Convert stamp to date
ban["ban_start_date"] = datetime.fromtimestamp(floor(ban["date"])).strftime("%Y/%m/%d at %H:%M:%S %Z")
ban["ban_end_date"] = datetime.fromtimestamp(floor(timestamp_now + exp)).strftime("%Y/%m/%d at %H:%M:%S %Z")
reasons.add(ban["reason"])
ban["start_date"] = datetime.fromtimestamp(floor(ban["date"])).astimezone().isoformat()
ban["end_date"] = datetime.fromtimestamp(floor(timestamp_now + exp)).astimezone().isoformat()
# builder = bans_builder(bans, list(reasons), list(remains))
# return render_template("bans.html", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))
return render_template("bans.html") # TODO
return render_template("bans.html", bans=bans)
@bans.route("/bans/ban", methods=["POST"])
@login_required
def bans_ban():
verify_data_in_form(
data={"bans": None},
err_message="Missing bans parameter on /bans/ban.",
redirect_url="bans",
next=True,
)
bans = request.form["bans"]
if not bans:
return handle_error("No bans.", "bans", True)
try:
bans = loads(bans)
except JSONDecodeError:
return handle_error("Invalid bans parameter on /bans/ban.", "bans", True)
redis_client = get_redis_client()
for ban in bans:
if not isinstance(ban, dict) or "ip" not in ban:
flash(f"Invalid ban: {ban}, skipping it ...", "error")
continue
reason = ban.get("reason", "ui")
try:
ban_end = datetime.fromisoformat(ban["end_date"])
except ValueError:
flash(f"Invalid ban: {ban}, skipping it ...", "error")
continue
current_time = datetime.now().astimezone()
ban_end = (ban_end - current_time).total_seconds()
if redis_client:
ok = redis_client.set(f"bans_ip_{ban['ip']}", dumps({"reason": reason, "date": time()}))
if not ok:
flash(f"Couldn't ban {ban['ip']} on redis", "error")
redis_client.expire(f"bans_ip_{ban['ip']}", int(ban_end))
resp = BW_INSTANCES_UTILS.ban(ban["ip"], ban_end, reason)
if resp:
flash(f"Couldn't ban {ban['ip']} on the following instances: {', '.join(resp)}", "error")
else:
flash(f"Successfully banned {ban['ip']}")
return redirect(url_for("loading", next=url_for("bans.bans_page"), message=f"Banning {len(bans)} IP{'s' if len(bans) > 1 else ''}"))
@bans.route("/bans/unban", methods=["POST"])
@login_required
def bans_unban():
verify_data_in_form(
data={"ips": None},
err_message="Missing bans parameter on /bans/unban.",
redirect_url="bans",
next=True,
)
unbans = request.form["ips"]
if not unbans:
return handle_error("No bans.", "ips", True)
try:
unbans = loads(unbans)
except JSONDecodeError:
return handle_error("Invalid ips parameter on /bans/unban.", "bans", True)
redis_client = get_redis_client()
for unban in unbans:
if "ip" not in unban:
flash(f"Invalid unban: {unban}, skipping it ...", "error")
continue
if redis_client:
if not redis_client.delete(f"bans_ip_{unban['ip']}"):
flash(f"Couldn't unban {unban['ip']} on redis", "error")
resp = BW_INSTANCES_UTILS.unban(unban["ip"])
if resp:
flash(f"Couldn't unban {unban['ip']} on the following instances: {', '.join(resp)}", "error")
else:
flash(f"Successfully unbanned {unban['ip']}")
return redirect(url_for("loading", next=url_for("bans.bans_page"), message=f"Unbanning {len(unbans)} IP{'s' if len(unbans) > 1 else ''}"))

View file

@ -3,6 +3,7 @@ from io import BytesIO
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 werkzeug.utils import secure_filename
from app.dependencies import DB
@ -21,7 +22,11 @@ def cache_page():
@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("_", "/")
if file_name.startswith("folder:"):
file_name = file_name.replace("_", "/")
else:
file_name = secure_filename(file_name)
cache_file = DB.get_job_cache_file(
job_name,
file_name,

View file

@ -6,8 +6,9 @@ from typing import Dict, Literal, Optional
from flask import Blueprint, flash, redirect, render_template, request, url_for
from flask_login import login_required
from werkzeug.utils import secure_filename
from app.dependencies import BW_CONFIG, DATA, DB # TODO: remember about DATA.load_from_file()
from app.dependencies import BW_CONFIG, DATA, DB
from app.routes.utils import handle_error, verify_data_in_form, wait_applying
@ -61,18 +62,24 @@ def configs_delete():
wait_applying()
db_configs = DB.get_custom_configs(with_drafts=True)
new_db_configs = []
configs_to_delete = set()
non_ui_configs = set()
for i, db_config in enumerate(db_configs):
for db_config in db_configs:
key = f"{(db_config['service_id'] + '/') if db_config['service_id'] else ''}{db_config['type']}/{db_config['name']}"
keep = True
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]
keep = False
break
if db_config.pop("template", None) or not keep:
continue
new_db_configs.append(db_config)
for non_ui_config in non_ui_configs:
DATA["TO_FLASH"].append(
@ -87,7 +94,7 @@ def configs_delete():
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
error = DB.save_custom_configs(db_configs, "ui")
error = DB.save_custom_configs(new_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})
@ -118,7 +125,7 @@ def 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(" ")
services = BW_CONFIG.get_config(global_only=True, with_drafts=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)
@ -162,32 +169,6 @@ def configs_new():
wait_applying()
config_type = config_type.lower()
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
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",
}
)
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
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"]
new_config = {
"type": config_type,
"name": config_name,
@ -196,10 +177,18 @@ def configs_new():
}
if service != "no service":
new_config["service_id"] = service
db_configs.append(new_config)
error = DB.save_custom_configs(db_configs, "ui")
error = DB.upsert_custom_config(config_type, config_name, new_config, service_id=new_config.get("service_id"), new=True)
if error:
if error == "The custom config already exists":
DATA["TO_FLASH"].append(
{
"content": f"Config {config_type}/{config_name}{' for service ' + service if service else ''} already exists",
"type": "error",
}
)
DATA.update({"RELOADING": False, "CONFIG_CHANGED": False})
return
DATA["TO_FLASH"].append({"content": f"An error occurred while saving the custom configs: {error}", "type": "error"})
return
DATA["TO_FLASH"].append(
@ -242,6 +231,7 @@ def configs_new():
def configs_edit(service: str, config_type: str, name: str):
if service == "global":
service = None
name = secure_filename(name)
db_config = DB.get_custom_config(config_type, name, service_id=service, with_data=True)
if not db_config:
@ -260,7 +250,7 @@ def configs_edit(service: str, config_type: str, name: str):
next=True,
)
new_service = request.form["service"]
services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ")
services = BW_CONFIG.get_config(global_only=True, with_drafts=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)
@ -284,7 +274,7 @@ def configs_edit(service: str, config_type: str, name: str):
redirect_url="configs/new",
next=True,
)
new_name = request.form["name"]
new_name = secure_filename(request.form["name"])
if not match(r"^[\w_-]{1,64}$", new_name):
return handle_error("Invalid name parameter on /configs/new.", "configs/new", True)

View file

@ -1,8 +1,5 @@
from io import BytesIO
from flask import Blueprint, jsonify, render_template, request, send_file
from flask import Blueprint, render_template
from flask_login import login_required
from werkzeug.utils import secure_filename
from app.dependencies import DB
@ -12,26 +9,4 @@ jobs = Blueprint("jobs", __name__)
@jobs.route("/jobs", methods=["GET"])
@login_required
def jobs_page():
# builder = jobs_builder(DB.get_jobs())
# return render_template("jobs.html", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))
return render_template("jobs.html") # TODO
@jobs.route("/jobs/download", methods=["GET"])
@login_required
def jobs_download():
plugin_id = request.args.get("plugin_id", "")
job_name = request.args.get("job_name", None)
file_name = request.args.get("file_name", None)
service_id = request.args.get("service_id", "")
if not plugin_id or not job_name or not file_name:
return jsonify({"status": "ko", "message": "plugin_id, job_name and file_name are required"}), 422
cache_file = DB.get_job_cache_file(job_name, file_name, service_id=service_id, plugin_id=plugin_id)
if not cache_file:
return jsonify({"status": "ko", "message": "file not found"}), 404
file = BytesIO(cache_file)
return send_file(file, as_attachment=True, download_name=secure_filename(file_name))
return render_template("jobs.html", jobs=DB.get_jobs())

View file

@ -23,6 +23,7 @@ def logs_page():
files.append(file.name)
current_file = secure_filename(request.args.get("file", ""))
page = request.args.get("page")
if current_file and current_file not in files:
return Response("No such file", 404)
@ -30,11 +31,14 @@ def logs_page():
if isabs(current_file) or ".." in current_file:
return error_message("Invalid file path", 400)
# raw_logs = ""
# if current_file:
# with logs_path.joinpath(current_file).open(encoding="utf-8") as f:
# raw_logs = f.read()
raw_logs = "Select a log file to view its contents"
page_num = 1
if current_file:
with logs_path.joinpath(current_file).open(encoding="utf-8") as f:
raw_logs = f.read().splitlines()
page_num = len(raw_logs) // 10000 + 1
if not page:
page = page_num
raw_logs = "\n".join(raw_logs[int(page) * 10000 - 10000 : int(page) * 10000]) # noqa: E203
# builder = logs_builder(files, current_file, raw_logs)
# return render_template("logs.html", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))
return render_template("logs.html") # TODO
return render_template("logs.html", logs=raw_logs, files=files, current_file=current_file, current_page=int(page), page_num=page_num)

View file

@ -1,4 +1,4 @@
from math import floor
from datetime import datetime
from flask import Blueprint, render_template
from flask_login import login_required
@ -12,25 +12,6 @@ reports = Blueprint("reports", __name__)
@login_required
def reports_page():
reports = BW_INSTANCES_UTILS.get_reports()
reasons = set()
countries = set()
methods = set()
codes = set()
# Prepare data
reports_items = []
for i, report in enumerate(reports):
report_item = {
"id": str(i),
"date": str(floor(report.pop("date"))),
} | report
reports_items.append(report_item)
reasons.add(report["reason"])
countries.add(report["country"])
methods.add(report["method"])
codes.add(report["code"])
# builder = reports_builder(reports_items, list(reasons), list(countries), list(methods), list(codes))
# return render_template("reports.html", data_server_builder=b64encode(dumps(builder).encode("utf-8")).decode("ascii"))
return render_template("reports.html")
for i in range(len(reports)):
reports[i]["date"] = datetime.fromtimestamp(reports[i]["date"]).astimezone().isoformat()
return render_template("reports.html", reports=list(filter(lambda x: 400 <= x["status"] < 500, reports))) # TODO: check why we need to filter this

View file

@ -365,63 +365,3 @@ def services_service_page(service: str):
type=search_type,
current_template=template,
)
# def update_service(config, variables, format_configs, server_name, old_server_name, operation, is_draft, was_draft, is_draft_unchanged):
# if request.form["operation"] == "edit":
# if is_draft_unchanged and len(variables) == 1 and "SERVER_NAME" in variables and server_name == old_server_name:
# return handle_error("The service was not edited because no values were changed.", "services", True)
# if request.form["operation"] == "new" and not variables:
# return handle_error("The service was not created because all values had the default value.", "services", True)
# # Delete
# if request.form["operation"] == "delete":
# is_service = BW_CONFIG.check_variables({"SERVER_NAME": request.form["SERVER_NAME"]}, config)
# if not is_service:
# error_message(f"Error while deleting the service {request.form['SERVER_NAME']}")
# if config.get(f"{request.form['SERVER_NAME'].split(' ')[0]}_SERVER_NAME", {"method": "scheduler"})["method"] != "ui":
# return handle_error("The service cannot be deleted because it has not been created with the UI.", "services", True)
# db_metadata = DB.get_metadata()
# def update_services(threaded: bool = False):
# wait_applying()
# manage_bunkerweb(
# "services",
# variables,
# old_server_name,
# variables.get("SERVER_NAME", ""),
# operation=operation,
# is_draft=is_draft,
# was_draft=was_draft,
# threaded=threaded,
# )
# if any(
# v
# for k, v in db_metadata.items()
# if k in ("custom_configs_changed", "external_plugins_changed", "pro_plugins_changed", "plugins_config_changed", "instances_changed")
# ):
# DATA["RELOADING"] = True
# DATA["LAST_RELOAD"] = time()
# Thread(target=update_services, args=(True,)).start()
# else:
# update_services()
# DATA["CONFIG_CHANGED"] = True
# message = ""
# if request.form["operation"] == "new":
# message = f"Creating {'draft ' if is_draft else ''}service {variables.get('SERVER_NAME', '').split(' ')[0]}"
# elif request.form["operation"] == "edit":
# message = f"Saving configuration for {'draft ' if is_draft else ''}service {old_server_name.split(' ')[0]}"
# elif request.form["operation"] == "delete":
# message = f"Deleting {'draft ' if was_draft and is_draft else ''}service {request.form.get('SERVER_NAME', '').split(' ')[0]}"
# return message

View file

@ -8,6 +8,7 @@ from typing import Any, Dict, Optional, Tuple, Union
from flask import Response, flash, redirect, request, session, url_for
from qrcode.main import QRCode
from redis import Redis, Sentinel
from regex import compile as re_compile
from app.models.instance import Instance
@ -320,3 +321,94 @@ def cors_required(f):
return f(*args, **kwargs)
return decorated_function
def get_redis_client():
redis_client = None
db_config = BW_CONFIG.get_config(
global_only=True,
methods=False,
filtered_settings=(
"USE_REDIS",
"REDIS_HOST",
"REDIS_PORT",
"REDIS_DB",
"REDIS_TIMEOUT",
"REDIS_KEEPALIVE_POOL",
"REDIS_SSL",
"REDIS_USERNAME",
"REDIS_PASSWORD",
"REDIS_SENTINEL_HOSTS",
"REDIS_SENTINEL_USERNAME",
"REDIS_SENTINEL_PASSWORD",
"REDIS_SENTINEL_MASTER",
),
)
use_redis = db_config.get("USE_REDIS", "no") == "yes"
redis_host = db_config.get("REDIS_HOST")
if use_redis and redis_host:
redis_port = db_config.get("REDIS_PORT", "6379")
if not redis_port.isdigit():
redis_port = "6379"
redis_port = int(redis_port)
redis_db = db_config.get("REDIS_DB", "0")
if not redis_db.isdigit():
redis_db = "0"
redis_db = int(redis_db)
redis_timeout = db_config.get("REDIS_TIMEOUT", "1000.0")
try:
redis_timeout = float(redis_timeout)
except ValueError:
redis_timeout = 1000.0
redis_keepalive_pool = db_config.get("REDIS_KEEPALIVE_POOL", "10")
if not redis_keepalive_pool.isdigit():
redis_keepalive_pool = "10"
redis_keepalive_pool = int(redis_keepalive_pool)
redis_ssl = db_config.get("REDIS_SSL", "no") == "yes"
username = db_config.get("REDIS_USERNAME", None) or None
password = db_config.get("REDIS_PASSWORD", None) or None
sentinel_hosts = db_config.get("REDIS_SENTINEL_HOSTS", [])
if isinstance(sentinel_hosts, str):
sentinel_hosts = [host.split(":") if ":" in host else (host, "26379") for host in sentinel_hosts.split(" ") if host]
if sentinel_hosts:
sentinel_username = db_config.get("REDIS_SENTINEL_USERNAME", None) or None
sentinel_password = db_config.get("REDIS_SENTINEL_PASSWORD", None) or None
sentinel_master = db_config.get("REDIS_SENTINEL_MASTER", "")
sentinel = Sentinel(
sentinel_hosts,
username=sentinel_username,
password=sentinel_password,
ssl=redis_ssl,
socket_timeout=redis_timeout,
socket_connect_timeout=redis_timeout,
socket_keepalive=True,
max_connections=redis_keepalive_pool,
)
redis_client = sentinel.slave_for(sentinel_master, db=redis_db, username=username, password=password)
else:
redis_client = Redis(
host=redis_host,
port=redis_port,
db=redis_db,
username=username,
password=password,
socket_timeout=redis_timeout,
socket_connect_timeout=redis_timeout,
socket_keepalive=True,
max_connections=redis_keepalive_pool,
ssl=redis_ssl,
)
try:
redis_client.ping()
except BaseException:
redis_client = None
flash("Couldn't connect to redis, ban list might be incomplete", "error")
return redis_client

View file

@ -0,0 +1,563 @@
$(document).ready(function () {
var actionLock = false;
let addBanNumber = 1;
const banNumber = parseInt($("#bans_number").val());
// Utility functions
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
function addHours(date, hours) {
const result = new Date(date);
result.setHours(result.getHours() + hours);
return result;
}
function formatDate(date) {
const year = date.getFullYear();
let month = date.getMonth() + 1; // Months are zero-based in JavaScript
let day = date.getDate();
// Pad month and day with leading zeros if needed
month = month < 10 ? "0" + month : month;
day = day < 10 ? "0" + day : day;
return `${year}-${month}-${day}`;
}
function formatTime(date) {
let hours = date.getHours();
let minutes = date.getMinutes();
// Pad hours and minutes with leading zeros if needed
hours = hours < 10 ? "0" + hours : hours;
minutes = minutes < 10 ? "0" + minutes : minutes;
return `${hours}:${minutes}`;
}
// Select the Flatpickr input elements
const flatpickrDatetime = $("[type='flapickr-datetime']");
// Get the current date and times
const currentDatetime = new Date();
const minDatetime = addHours(currentDatetime, 1);
const defaultDatetime = addDays(currentDatetime, 1);
// Format dates and times
const minDateStr = formatDate(minDatetime);
const minTimeStr = formatTime(minDatetime);
// Create the minMaxTime table
const minMaxTable = {
[minDateStr]: {
minTime: minTimeStr,
},
};
const getTimeZoneOffset = () => {
const offset = -currentDatetime.getTimezoneOffset(); // getTimezoneOffset returns minutes behind UTC
const sign = offset >= 0 ? "+" : "-";
const absOffset = Math.abs(offset);
const hours = String(Math.floor(absOffset / 60)).padStart(2, "0");
const minutes = String(absOffset % 60).padStart(2, "0");
return `${sign}${hours}:${minutes}`;
};
// Initialize Flatpickr with altInput and altFormat
const originalFlatpickr = flatpickrDatetime.flatpickr({
enableTime: true,
dateFormat: "Y-m-d\\TH:i:S", // ISO format
altInput: true,
altFormat: "F j, Y h:i", // User-friendly display format
time_24hr: true,
defaultDate: defaultDatetime,
minDate: minDatetime,
plugins: [
new minMaxTimePlugin({
table: minMaxTable,
}),
],
});
const setupUnbanModal = (bans) => {
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100">
<li class="list-group-item align-items-center text-center bg-secondary text-white" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">IP Address</div>
</div>
</li>
<li class="list-group-item align-items-center text-center bg-secondary text-white" style="flex: 1 0;">
<div class="fw-bold">Time left</div>
</li>
</ul>`,
);
$("#selected-ips-unban").append(list);
bans.forEach((ban) => {
// Create the list item using template literals
const list = $(
`<ul class="list-group list-group-horizontal d-flex w-100"></ul>`,
);
const listItem =
$(`<li class="list-group-item align-items-center" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">${ban.ip}</div>
</div>
</li>`);
list.append(listItem);
const timeLeft =
$(`<li class="list-group-item align-items-center" style="flex: 1 0;">
<div class="ms-2 me-auto">
${ban.time_remaining}
</div>
</li>`);
list.append(timeLeft);
$("#selected-ips-unban").append(list);
});
const unban_modal = $("#modal-unban-ips");
const modal = new bootstrap.Modal(unban_modal);
unban_modal
.find(".alert")
.text(
`Are you sure you want to unban the selected IP address${"es".repeat(
bans.length > 1,
)}?`,
);
modal.show();
$("#selected-ips-input-unban").val(JSON.stringify(bans));
};
const debounce = (func, delay) => {
let debounceTimer;
return (...args) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => func.apply(this, args), delay);
};
};
const layout = {
topStart: {},
bottomEnd: {},
};
if (banNumber > 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_bans",
exportOptions: {
modifier: {
search: "none",
},
},
},
{
extend: "excel",
text: '<span class="tf-icons bx bx-table bx-18px me-2"></span>Excel',
filename: "bw_bans",
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: "unban_ips",
className: "text-danger",
},
],
},
{
extend: "add_ban",
},
];
$("#modal-unban-ips").on("hidden.bs.modal", function () {
$("#selected-ips-unban").empty();
$("#selected-ips-input-unban").val("");
});
const getSelectedBans = () => {
const bans = [];
$("tr.selected").each(function () {
const ip = $(this).find("td:eq(2)").text().trim();
const time_remaining = $(this).find("td:eq(5)").text().trim();
bans.push({ ip: ip, time_remaining: time_remaining });
});
return bans;
};
$.fn.dataTable.ext.buttons.add_ban = {
text: '<span class="tf-icons bx bx-plus-circle bx-18px me-2"></span>Add<span class="d-none d-md-inline"> ban(s)</span>',
className: "btn btn-sm btn-outline-bw-green",
action: function (e, dt, node, config) {
const ban_modal = $("#modal-ban-ips");
const modal = new bootstrap.Modal(ban_modal);
modal.show();
},
};
$.fn.dataTable.ext.buttons.unban_ips = {
text: '<span class="tf-icons bx bxs-buoy bx-18px me-2"></span>Unban',
action: function (e, dt, node, config) {
if (actionLock) {
return;
}
actionLock = true;
$(".dt-button-background").click();
const bans = getSelectedBans();
if (bans.length === 0) {
actionLock = false;
return;
}
setupUnbanModal(bans);
actionLock = false;
},
};
$(".ban-start-date, .ban-end-date").each(function () {
const isoDateStr = $(this).text().trim();
// Parse the ISO format date string
const date = new Date(isoDateStr);
// Check if the date is valid
if (!isNaN(date)) {
// Convert to local date and time string
const localDateStr = date.toLocaleString();
// Update the text content with the local date string
$(this).text(localDateStr);
} else {
// Handle invalid date
console.error(`Invalid date string: ${isoDateStr}`);
$(this).text("Invalid date");
}
});
const bans_table = new DataTable("#bans", {
columnDefs: [
{
orderable: false,
render: DataTable.render.select(),
targets: 0,
},
{
orderable: false,
targets: -1,
},
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
$(td).addClass("align-items-center"); // Apply 'text-center' class to <td>
},
},
],
order: [[4, "asc"]],
autoFill: false,
responsive: true,
select: {
style: "multi+shift",
selector: "td:first-child",
headerCheckbox: false,
},
layout: layout,
language: {
info: "Showing _START_ to _END_ of _TOTAL_ bans",
infoEmpty: "No bans available",
infoFiltered: "(filtered from _MAX_ total bans)",
lengthMenu: "Display _MENU_ bans",
zeroRecords: "No matching bans found",
select: {
rows: {
_: "Selected %d bans",
0: "No bans selected",
1: "Selected 1 ban",
},
},
},
initComplete: function (settings, json) {
$("#bans_wrapper .btn-secondary").removeClass("btn-secondary");
$("#bans_wrapper th").addClass("text-center");
},
});
bans_table.on("mouseenter", "td", function () {
if (bans_table.cell(this).index() === undefined) return;
const rowIdx = bans_table.cell(this).index().row;
bans_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
bans_table
.cells()
.nodes()
.each(function (el) {
if (bans_table.cell(el).index().row === rowIdx)
el.classList.add("highlight");
});
});
bans_table.on("mouseleave", "td", function () {
bans_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
bans_table.rows({ page: "current" }).select();
} else {
// Deselect all rows on the current page
bans_table.rows({ page: "current" }).deselect();
}
});
$(".unban-ip").on("click", function () {
$this = $(this);
setupUnbanModal([
{ ip: $this.data("ip"), time_remaining: $this.data("time-left") },
]);
});
$("#add-ban").on("click", function () {
const originalBan = $("#ban-1");
const banClone = originalBan.clone();
banClone.attr("id", `ban-${++addBanNumber}`);
banClone
.find("input[name='ip']")
.removeClass("is-valid is-invalid")
.val("");
banClone.find("[readonly='readonly']").remove();
banClone.find(".flatpickr-input").flatpickr({
enableTime: true,
dateFormat: "Y-m-d\\TH:i:S", // ISO format
altInput: true,
altFormat: "F j, Y h:i", // User-friendly display format
time_24hr: true,
defaultDate: defaultDatetime,
minDate: minDatetime,
plugins: [
new minMaxTimePlugin({
table: minMaxTable,
}),
],
});
banClone.find("input[name='reason']").val("ui");
const deleteButton = banClone.find(".delete-ban");
deleteButton.removeClass("disabled");
$("#bans-container").append(banClone);
});
$("#clear-bans").on("click", function () {
$("#bans-container")
.find("li")
.each(function () {
if (
$(this).attr("id") === "ban-1" ||
$(this).attr("id") === "bans-header"
)
return;
$(this).remove();
});
});
$(document).on("click", ".delete-ban", function () {
const banContainer = $(this).closest("li");
if (banContainer.attr("id") === "ban-1") return;
banContainer.remove();
});
$("#modal-ban-ips").on("hidden.bs.modal", function () {
$("#clear-bans").trigger("click");
const firstBan = $("#ban-1");
firstBan.find("input[name='ip']").val("");
firstBan.find("input[name='reason']").val("ui");
originalFlatpickr.setDate(defaultDatetime);
});
const ipRegex = new RegExp(
/^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?:\.(?!$)|$)){4}$|^((?:[A-Fa-f0-9]{1,4}:){7}[A-Fa-f0-9]{1,4}|(?:[A-Fa-f0-9]{1,4}:){1,7}:|:(?::[A-Fa-f0-9]{1,4}){1,7}|::)$/i,
);
const validateBan = (ban, ipSet) => {
const value = ban.val().trim();
let errorMessage = "";
let isValid = true;
if (value === "") {
errorMessage = "Please enter an IP address.";
isValid = false;
} else if (!ipRegex.test(value)) {
errorMessage = "Please enter a valid IP address.";
isValid = false;
} else if (ipSet.has(value)) {
errorMessage = "This IP address has already been entered.";
isValid = false;
}
// Toggle valid/invalid classes
ban.toggleClass("is-valid", isValid).toggleClass("is-invalid", !isValid);
// Manage the invalid-feedback element
let $feedback = ban.next(".invalid-feedback");
if (!$feedback.length) {
$feedback = $('<div class="invalid-feedback"></div>').insertAfter(ban);
}
$feedback.text(errorMessage);
return isValid;
};
$("#bans-container").on("input", "input[name='ip']", function () {
debounce(() => {
const $input = $(this);
const ipSet = new Set();
// Gather all other IPs except the current one
$("#bans-container")
.find("input[name='ip']")
.not($input)
.each(function () {
ipSet.add($(this).val().trim());
});
validateBan($input, ipSet);
}, 100)();
});
$("#bans-container").on("focusout", "input[name='ip']", function () {
$(this).removeClass("is-valid");
});
$("#bans-form").on("submit", function (e) {
e.preventDefault();
let allValid = true;
const ipSet = new Set();
$("#bans-container")
.find("input[name='ip']")
.each(function () {
const $input = $(this);
const ip = $input.val().trim();
// Validate and check for duplicates
if (!validateBan($input, ipSet)) {
allValid = false;
} else {
ipSet.add(ip);
}
});
if (!allValid) return;
const bans = [];
$("#bans-container")
.find("li.rounded-0")
.each(function () {
$this = $(this);
const ip = $this.find("input[name='ip']").val().trim();
const end_date = $this.find(".flatpickr-input").val();
const reason = $this.find("input[name='reason']").val().trim();
bans.push({
ip: ip,
end_date: `${end_date}${getTimeZoneOffset()}`,
reason: reason,
});
});
console.log(bans);
const form = $("<form>", {
method: "POST",
action: `${window.location.pathname}/ban`,
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: "bans",
value: JSON.stringify(bans),
}),
);
// Append the form to the body and submit it
form.appendTo("body").submit();
});
});

View file

@ -67,6 +67,22 @@ $(document).ready(function () {
},
];
$(".cache-last-update-date").each(function () {
const isoDateStr = $(this).text().trim();
// Parse the ISO format date string
const date = new Date(isoDateStr);
// Check if the date is valid
if (!isNaN(date)) {
// Convert to local date and time string
const localDateStr = date.toLocaleString();
// Update the text content with the local date string
$(this).text(localDateStr);
}
});
const cache_table = new DataTable("#cache", {
columnDefs: [
{
@ -80,7 +96,7 @@ $(document).ready(function () {
{
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>
$(td).addClass("align-items-center"); // Apply 'text-center' class to <td>
},
},
],
@ -94,20 +110,15 @@ $(document).ready(function () {
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_wrapper th").addClass("text-center");
},
});
cache_table.on("mouseenter", "td", function () {
if (cache_table.cell(this).index() === undefined) return;
const rowIdx = cache_table.cell(this).index().row;
cache_table

View file

@ -6,15 +6,15 @@ $(document).ready(function () {
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;">
<li class="list-group-item align-items-center text-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;">
<li class="list-group-item align-items-center text-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;">
<li class="list-group-item align-items-center text-center bg-secondary text-white" style="flex: 1 1 0;">
<div class="fw-bold">Service</div>
</li>
</ul>`,
@ -28,7 +28,7 @@ $(document).ready(function () {
// Create the list item using template literals
const listItem =
$(`<li class="list-group-item align-items-center text-center" style="flex: 1 1 0;">
$(`<li class="list-group-item align-items-center" style="flex: 1 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">${config.name}</div>
</div>
@ -235,11 +235,11 @@ $(document).ready(function () {
{
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>
$(td).addClass("align-items-center"); // Apply 'text-center' class to <td>
},
},
],
order: [[1, "desc"]],
order: [[1, "asc"]],
autoFill: false,
responsive: true,
select: {
@ -264,10 +264,12 @@ $(document).ready(function () {
},
initComplete: function (settings, json) {
$("#configs_wrapper .btn-secondary").removeClass("btn-secondary");
$("#configs_wrapper th").addClass("text-center");
},
});
configs_table.on("mouseenter", "td", function () {
if (configs_table.cell(this).index() === undefined) return;
const rowIdx = configs_table.cell(this).index().row;
configs_table

View file

@ -278,12 +278,12 @@ $(document).ready(function () {
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;">
<li class="list-group-item align-items-center text-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;">
<li class="list-group-item align-items-center text-center bg-secondary text-white" style="flex: 1 0;">
<div class="fw-bold">Health</div>
</li>
</ul>`,
@ -298,7 +298,7 @@ $(document).ready(function () {
// Create the list item using template literals
const listItem =
$(`<li class="list-group-item align-items-center text-center" style="flex: 1 0;">
$(`<li class="list-group-item align-items-center" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">${instance}</div>
</div>
@ -324,6 +324,26 @@ $(document).ready(function () {
},
};
$(".instance-creation-date, .instance-last-seen-date").each(function () {
const isoDateStr = $(this).text().trim();
// Parse the ISO format date string
const date = new Date(isoDateStr);
// Check if the date is valid
if (!isNaN(date)) {
// Convert to local date and time string
const localDateStr = date.toLocaleString();
// Update the text content with the local date string
$(this).text(localDateStr);
} else {
// Handle invalid date
console.error(`Invalid date string: ${isoDateStr}`);
$(this).text("Invalid date");
}
});
const instances_table = new DataTable("#instances", {
columnDefs: [
{
@ -342,7 +362,7 @@ $(document).ready(function () {
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
$(td).addClass("text-center"); // Apply 'text-center' class to <td>
$(td).addClass("align-items-center"); // Apply 'text-center' class to <td>
},
},
],
@ -371,10 +391,12 @@ $(document).ready(function () {
},
initComplete: function (settings, json) {
$("#instances_wrapper .btn-secondary").removeClass("btn-secondary");
$("#instances_wrapper th").addClass("text-center");
},
});
instances_table.on("mouseenter", "td", function () {
if (instances_table.cell(this).index() === undefined) return;
const rowIdx = instances_table.cell(this).index().row;
instances_table

View file

@ -0,0 +1,158 @@
$(document).ready(function () {
const jobNumber = parseInt($("#job_number").val());
const layout = {
topStart: {},
bottomEnd: {},
};
if (jobNumber > 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)",
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_jobs",
exportOptions: {
modifier: {
search: "none",
},
},
},
{
extend: "excel",
text: '<span class="tf-icons bx bx-table bx-18px me-2"></span>Excel',
filename: "bw_jobs",
exportOptions: {
modifier: {
search: "none",
},
},
},
],
},
];
$(".history-start-date, .history-end-date").each(function () {
const isoDateStr = $(this).text().trim();
// Parse the ISO format date string
const date = new Date(isoDateStr);
// Check if the date is valid
if (!isNaN(date)) {
// Convert to local date and time string
const localDateStr = date.toLocaleString();
// Update the text content with the local date string
$(this).text(localDateStr);
} else {
// Handle invalid date
console.error(`Invalid date string: ${isoDateStr}`);
$(this).text("Invalid date");
}
});
const jobs_table = new DataTable("#jobs", {
columnDefs: [
{
orderable: false,
targets: -1,
},
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
$(td).addClass("align-items-center"); // Apply 'text-center' class to <td>
},
},
],
order: [[1, "asc"]],
autoFill: false,
responsive: true,
layout: layout,
language: {
info: "Showing _START_ to _END_ of _TOTAL_ jobs",
infoEmpty: "No jobs available",
infoFiltered: "(filtered from _MAX_ total jobs)",
lengthMenu: "Display _MENU_ jobs",
zeroRecords: "No matching jobs found",
},
initComplete: function (settings, json) {
$("#jobs_wrapper .btn-secondary").removeClass("btn-secondary");
$("#jobs_wrapper th").addClass("text-center");
},
});
jobs_table.on("mouseenter", "td", function () {
if (jobs_table.cell(this).index() === undefined) return;
const rowIdx = jobs_table.cell(this).index().row;
jobs_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
jobs_table
.cells()
.nodes()
.each(function (el) {
if (jobs_table.cell(el).index().row === rowIdx)
el.classList.add("highlight");
});
});
jobs_table.on("mouseleave", "td", function () {
jobs_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
});
$(".show-history").on("click", function () {
const historyModal = $("#modal-job-history");
const job = $(this).data("job");
const plugin = $(this).data("plugin");
historyModal.find(".modal-title").text(`Job ${job} History`);
const history = $(`#job-${job}-${plugin}-history`).clone();
history.removeClass("visually-hidden");
historyModal.find(".modal-body").html(history);
const modal = new bootstrap.Modal(historyModal);
modal.show();
});
});

View file

@ -0,0 +1,43 @@
$(document).ready(function () {
const editorElement = $("#raw-logs");
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);
$("#copy-logs").click(function () {
$this = $(this);
editor.selectAll();
editor.focus();
navigator.clipboard
.writeText(editor.getSelectedText())
.then(() => {
// Success
console.log("Copied to clipboard");
})
.catch((err) => {
// Error
console.error("Failed to copy to clipboard", err);
});
$this.attr("data-bs-original-title", "Copied!").tooltip("show");
// Hide tooltip after 2 seconds
setTimeout(() => {
$this.tooltip("hide").attr("data-bs-original-title", "");
}, 2000);
});
});

View file

@ -0,0 +1,151 @@
$(document).ready(function () {
const reportsNumber = parseInt($("#reports_number").val());
const layout = {
topStart: {},
bottomEnd: {},
};
if (reportsNumber > 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_report",
exportOptions: {
modifier: {
search: "none",
},
},
},
{
extend: "excel",
text: '<span class="tf-icons bx bx-table bx-18px me-2"></span>Excel',
filename: "bw_report",
exportOptions: {
modifier: {
search: "none",
},
},
},
],
},
];
$(".report-date").each(function () {
const isoDateStr = $(this).text().trim();
// Parse the ISO format date string
const date = new Date(isoDateStr);
// Check if the date is valid
if (!isNaN(date)) {
// Convert to local date and time string
const localDateStr = date.toLocaleString();
// Update the text content with the local date string
$(this).text(localDateStr);
}
});
const reports_table = new DataTable("#reports", {
columnDefs: [
{
orderable: false,
targets: -1,
},
{
visible: false,
targets: [6, -1],
},
{
targets: "_all", // Target all columns
createdCell: function (td, cellData, rowData, row, col) {
$(td).addClass("align-items-center"); // Apply 'text-center' class to <td>
},
},
],
order: [[0, "desc"]],
autoFill: false,
responsive: true,
layout: layout,
language: {
info: "Showing _START_ to _END_ of _TOTAL_ reports",
infoEmpty: "No reports available",
infoFiltered: "(filtered from _MAX_ total reports)",
lengthMenu: "Display _MENU_ reports",
zeroRecords: "No matching reports found",
select: {
rows: {
_: "Selected %d reports",
0: "No reports selected",
1: "Selected 1 report",
},
},
},
initComplete: function (settings, json) {
$("#reports_wrapper .btn-secondary").removeClass("btn-secondary");
$("#reports_wrapper th").addClass("text-center");
},
});
reports_table.on("mouseenter", "td", function () {
if (reports_table.cell(this).index() === undefined) return;
const rowIdx = reports_table.cell(this).index().row;
reports_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
reports_table
.cells()
.nodes()
.each(function (el) {
if (reports_table.cell(el).index().row === rowIdx)
el.classList.add("highlight");
});
});
reports_table.on("mouseleave", "td", function () {
reports_table
.cells()
.nodes()
.each((el) => el.classList.remove("highlight"));
});
});

View file

@ -6,12 +6,12 @@ $(document).ready(function () {
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;">
<li class="list-group-item align-items-center text-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;">
<li class="list-group-item align-items-center text-center bg-secondary text-white" style="flex: 1 0;">
<div class="fw-bold">Type</div>
</li>
</ul>`,
@ -25,7 +25,7 @@ $(document).ready(function () {
// Create the list item using template literals
const listItem =
$(`<li class="list-group-item align-items-center text-center" style="flex: 1 0;">
$(`<li class="list-group-item align-items-center" style="flex: 1 0;">
<div class="ms-2 me-auto">
<div class="fw-bold">${service}</div>
</div>
@ -266,6 +266,26 @@ $(document).ready(function () {
},
};
$(".service-creation-date, .service-last-update-date").each(function () {
const isoDateStr = $(this).text().trim();
// Parse the ISO format date string
const date = new Date(isoDateStr);
// Check if the date is valid
if (!isNaN(date)) {
// Convert to local date and time string
const localDateStr = date.toLocaleString();
// Update the text content with the local date string
$(this).text(localDateStr);
} else {
// Handle invalid date
console.error(`Invalid date string: ${isoDateStr}`);
$(this).text("Invalid date");
}
});
const services_table = new DataTable("#services", {
columnDefs: [
{
@ -280,7 +300,7 @@ $(document).ready(function () {
{
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>
$(td).addClass("align-items-center"); // Apply 'text-center' class to <td>
},
},
],
@ -309,10 +329,12 @@ $(document).ready(function () {
},
initComplete: function (settings, json) {
$("#services_wrapper .btn-secondary").removeClass("btn-secondary");
$("#services_wrapper th").addClass("text-center");
},
});
services_table.on("mouseenter", "td", function () {
if (services_table.cell(this).index() === undefined) return;
const rowIdx = services_table.cell(this).index().row;
services_table

View file

@ -682,6 +682,9 @@ $(document).ready(() => {
</div>
`);
multipleShow.html(`<i class="bx bx-hide bx-sm"></i>&nbsp;SHOW`);
multipleClone.find(".multiple-collapse").collapse("hide");
// Insert the new element in the correct order based on suffix
let inserted = false;
existingContainers.each(function () {
@ -698,9 +701,6 @@ $(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);
@ -718,7 +718,7 @@ $(document).ready(() => {
setTimeout(() => {
showMultiple.trigger("click");
highlightSettings(multipleClone);
}, 50);
}, 30);
});
$(document).on("click", ".remove-multiple", function () {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,349 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.minMaxTimePlugin = factory());
}(this, (function () { 'use strict';
var pad = function (number, length) {
if (length === void 0) { length = 2; }
return ("000" + number).slice(length * -1);
};
var int = function (bool) { return (bool === true ? 1 : 0); };
var monthToStr = function (monthNumber, shorthand, locale) { return locale.months[shorthand ? "shorthand" : "longhand"][monthNumber]; };
var formats = {
// get the date in UTC
Z: function (date) { return date.toISOString(); },
// weekday name, short, e.g. Thu
D: function (date, locale, options) {
return locale.weekdays.shorthand[formats.w(date, locale, options)];
},
// full month name e.g. January
F: function (date, locale, options) {
return monthToStr(formats.n(date, locale, options) - 1, false, locale);
},
// padded hour 1-12
G: function (date, locale, options) {
return pad(formats.h(date, locale, options));
},
// hours with leading zero e.g. 03
H: function (date) { return pad(date.getHours()); },
// day (1-30) with ordinal suffix e.g. 1st, 2nd
J: function (date, locale) {
return locale.ordinal !== undefined
? date.getDate() + locale.ordinal(date.getDate())
: date.getDate();
},
// AM/PM
K: function (date, locale) { return locale.amPM[int(date.getHours() > 11)]; },
// shorthand month e.g. Jan, Sep, Oct, etc
M: function (date, locale) {
return monthToStr(date.getMonth(), true, locale);
},
// seconds 00-59
S: function (date) { return pad(date.getSeconds()); },
// unix timestamp
U: function (date) { return date.getTime() / 1000; },
W: function (date, _, options) {
return options.getWeek(date);
},
// full year e.g. 2016, padded (0001-9999)
Y: function (date) { return pad(date.getFullYear(), 4); },
// day in month, padded (01-30)
d: function (date) { return pad(date.getDate()); },
// hour from 1-12 (am/pm)
h: function (date) { return (date.getHours() % 12 ? date.getHours() % 12 : 12); },
// minutes, padded with leading zero e.g. 09
i: function (date) { return pad(date.getMinutes()); },
// day in month (1-30)
j: function (date) { return date.getDate(); },
// weekday name, full, e.g. Thursday
l: function (date, locale) {
return locale.weekdays.longhand[date.getDay()];
},
// padded month number (01-12)
m: function (date) { return pad(date.getMonth() + 1); },
// the month number (1-12)
n: function (date) { return date.getMonth() + 1; },
// seconds 0-59
s: function (date) { return date.getSeconds(); },
// Unix Milliseconds
u: function (date) { return date.getTime(); },
// number of the day of the week
w: function (date) { return date.getDay(); },
// last two digits of year e.g. 16 for 2016
y: function (date) { return String(date.getFullYear()).substring(2); },
};
var defaults = {
_disable: [],
allowInput: false,
allowInvalidPreload: false,
altFormat: "F j, Y",
altInput: false,
altInputClass: "form-control input",
animate: typeof window === "object" &&
window.navigator.userAgent.indexOf("MSIE") === -1,
ariaDateFormat: "F j, Y",
autoFillDefaultTime: true,
clickOpens: true,
closeOnSelect: true,
conjunction: ", ",
dateFormat: "Y-m-d",
defaultHour: 12,
defaultMinute: 0,
defaultSeconds: 0,
disable: [],
disableMobile: false,
enableSeconds: false,
enableTime: false,
errorHandler: function (err) {
return typeof console !== "undefined" && console.warn(err);
},
getWeek: function (givenDate) {
var date = new Date(givenDate.getTime());
date.setHours(0, 0, 0, 0);
// Thursday in current week decides the year.
date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
// January 4 is always in week 1.
var week1 = new Date(date.getFullYear(), 0, 4);
// Adjust to Thursday in week 1 and count number of weeks from date to week1.
return (1 +
Math.round(((date.getTime() - week1.getTime()) / 86400000 -
3 +
((week1.getDay() + 6) % 7)) /
7));
},
hourIncrement: 1,
ignoredFocusElements: [],
inline: false,
locale: "default",
minuteIncrement: 5,
mode: "single",
monthSelectorType: "dropdown",
nextArrow: "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 17 17'><g></g><path d='M13.207 8.472l-7.854 7.854-0.707-0.707 7.146-7.146-7.146-7.148 0.707-0.707 7.854 7.854z' /></svg>",
noCalendar: false,
now: new Date(),
onChange: [],
onClose: [],
onDayCreate: [],
onDestroy: [],
onKeyDown: [],
onMonthChange: [],
onOpen: [],
onParseConfig: [],
onReady: [],
onValueUpdate: [],
onYearChange: [],
onPreCalendarPosition: [],
plugins: [],
position: "auto",
positionElement: undefined,
prevArrow: "<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' viewBox='0 0 17 17'><g></g><path d='M5.207 8.471l7.146 7.147-0.707 0.707-7.853-7.854 7.854-7.853 0.707 0.707-7.147 7.146z' /></svg>",
shorthandCurrentMonth: false,
showMonths: 1,
static: false,
time_24hr: false,
weekNumbers: false,
wrap: false,
};
var english = {
weekdays: {
shorthand: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
longhand: [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
],
},
months: {
shorthand: [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
],
longhand: [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
],
},
daysInMonth: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
firstDayOfWeek: 0,
ordinal: function (nth) {
var s = nth % 100;
if (s > 3 && s < 21)
return "th";
switch (s % 10) {
case 1:
return "st";
case 2:
return "nd";
case 3:
return "rd";
default:
return "th";
}
},
rangeSeparator: " to ",
weekAbbreviation: "Wk",
scrollTitle: "Scroll to increment",
toggleTitle: "Click to toggle",
amPM: ["AM", "PM"],
yearAriaLabel: "Year",
monthAriaLabel: "Month",
hourAriaLabel: "Hour",
minuteAriaLabel: "Minute",
time_24hr: false,
};
var createDateFormatter = function (_a) {
var _b = _a.config, config = _b === void 0 ? defaults : _b, _c = _a.l10n, l10n = _c === void 0 ? english : _c, _d = _a.isMobile, isMobile = _d === void 0 ? false : _d;
return function (dateObj, frmt, overrideLocale) {
var locale = overrideLocale || l10n;
if (config.formatDate !== undefined && !isMobile) {
return config.formatDate(dateObj, frmt, locale);
}
return frmt
.split("")
.map(function (c, i, arr) {
return formats[c] && arr[i - 1] !== "\\"
? formats[c](dateObj, locale, config)
: c !== "\\"
? c
: "";
})
.join("");
};
};
/**
* Compute the difference in dates, measured in ms
*/
function compareDates(date1, date2, timeless) {
if (timeless === void 0) { timeless = true; }
if (timeless !== false) {
return (new Date(date1.getTime()).setHours(0, 0, 0, 0) -
new Date(date2.getTime()).setHours(0, 0, 0, 0));
}
return date1.getTime() - date2.getTime();
}
/**
* Compute the difference in times, measured in ms
*/
function compareTimes(date1, date2) {
return (3600 * (date1.getHours() - date2.getHours()) +
60 * (date1.getMinutes() - date2.getMinutes()) +
date1.getSeconds() -
date2.getSeconds());
}
var calculateSecondsSinceMidnight = function (hours, minutes, seconds) {
return hours * 3600 + minutes * 60 + seconds;
};
var parseSeconds = function (secondsSinceMidnight) {
var hours = Math.floor(secondsSinceMidnight / 3600), minutes = (secondsSinceMidnight - hours * 3600) / 60;
return [hours, minutes, secondsSinceMidnight - hours * 3600 - minutes * 60];
};
function minMaxTimePlugin(config) {
if (config === void 0) { config = {}; }
var state = {
formatDate: createDateFormatter({}),
tableDateFormat: config.tableDateFormat || "Y-m-d",
defaults: {
minTime: undefined,
maxTime: undefined,
},
};
function findDateTimeLimit(date) {
if (config.table !== undefined) {
return config.table[state.formatDate(date, state.tableDateFormat)];
}
return config.getTimeLimits && config.getTimeLimits(date);
}
return function (fp) {
return {
onReady: function () {
state.formatDate = this.formatDate;
state.defaults = {
minTime: this.config.minTime && state.formatDate(this.config.minTime, "H:i"),
maxTime: this.config.maxTime && state.formatDate(this.config.maxTime, "H:i"),
};
fp.loadedPlugins.push("minMaxTime");
},
onChange: function () {
var latest = this.latestSelectedDateObj;
var matchingTimeLimit = latest && findDateTimeLimit(latest);
if (latest && matchingTimeLimit !== undefined) {
this.set(matchingTimeLimit);
fp.config.minTime.setFullYear(latest.getFullYear());
fp.config.maxTime.setFullYear(latest.getFullYear());
fp.config.minTime.setMonth(latest.getMonth());
fp.config.maxTime.setMonth(latest.getMonth());
fp.config.minTime.setDate(latest.getDate());
fp.config.maxTime.setDate(latest.getDate());
if (fp.config.minTime > fp.config.maxTime) {
var minBound = calculateSecondsSinceMidnight(fp.config.minTime.getHours(), fp.config.minTime.getMinutes(), fp.config.minTime.getSeconds());
var maxBound = calculateSecondsSinceMidnight(fp.config.maxTime.getHours(), fp.config.maxTime.getMinutes(), fp.config.maxTime.getSeconds());
var currentTime = calculateSecondsSinceMidnight(latest.getHours(), latest.getMinutes(), latest.getSeconds());
if (currentTime > maxBound && currentTime < minBound) {
var result = parseSeconds(minBound);
fp.setDate(new Date(latest.getTime()).setHours(result[0], result[1], result[2]), false);
}
}
else {
if (compareDates(latest, fp.config.maxTime, false) > 0) {
fp.setDate(new Date(latest.getTime()).setHours(fp.config.maxTime.getHours(), fp.config.maxTime.getMinutes(), fp.config.maxTime.getSeconds(), fp.config.maxTime.getMilliseconds()), false);
}
else if (compareDates(latest, fp.config.minTime, false) < 0) {
fp.setDate(new Date(latest.getTime()).setHours(fp.config.minTime.getHours(), fp.config.minTime.getMinutes(), fp.config.minTime.getSeconds(), fp.config.minTime.getMilliseconds()), false);
}
}
}
else {
var newMinMax = state.defaults || {
minTime: undefined,
maxTime: undefined,
};
this.set(newMinMax);
if (!latest)
return;
var _a = fp.config, minTime = _a.minTime, maxTime = _a.maxTime;
if (minTime && compareTimes(latest, minTime) < 0) {
fp.setDate(new Date(latest.getTime()).setHours(minTime.getHours(), minTime.getMinutes(), minTime.getSeconds(), minTime.getMilliseconds()), false);
}
else if (maxTime && compareTimes(latest, maxTime) > 0) {
fp.setDate(new Date(latest.getTime()).setHours(maxTime.getHours(), maxTime.getMinutes(), maxTime.getSeconds(), maxTime.getMilliseconds()));
}
//
}
},
};
};
}
return minMaxTimePlugin;
})));

View file

@ -0,0 +1,883 @@
.flatpickr-calendar {
background: transparent;
opacity: 0;
display: none;
text-align: center;
visibility: hidden;
padding: 0;
-webkit-animation: none;
animation: none;
direction: ltr;
border: 0;
font-size: 14px;
line-height: 24px;
border-radius: 5px;
position: absolute;
width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-ms-touch-action: manipulation;
touch-action: manipulation;
background: #fff;
-webkit-box-shadow: 1px 0 0 #eee, -1px 0 0 #eee, 0 1px 0 #eee, 0 -1px 0 #eee, 0 3px 13px rgba(0,0,0,0.08);
box-shadow: 1px 0 0 #eee, -1px 0 0 #eee, 0 1px 0 #eee, 0 -1px 0 #eee, 0 3px 13px rgba(0,0,0,0.08);
}
.flatpickr-calendar.open,
.flatpickr-calendar.inline {
opacity: 1;
max-height: 640px;
visibility: visible;
}
.flatpickr-calendar.open {
display: inline-block;
z-index: 99999;
}
.flatpickr-calendar.animate.open {
-webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.flatpickr-calendar.inline {
display: block;
position: relative;
top: 2px;
}
.flatpickr-calendar.static {
position: absolute;
top: calc(100% + 2px);
}
.flatpickr-calendar.static.open {
z-index: 999;
display: block;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
-webkit-box-shadow: none !important;
box-shadow: none !important;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
-webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
}
.flatpickr-calendar .hasWeeks .dayContainer,
.flatpickr-calendar .hasTime .dayContainer {
border-bottom: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.flatpickr-calendar .hasWeeks .dayContainer {
border-left: 0;
}
.flatpickr-calendar.hasTime .flatpickr-time {
height: 40px;
border-top: 1px solid #eee;
}
.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
height: auto;
}
.flatpickr-calendar:before,
.flatpickr-calendar:after {
position: absolute;
display: block;
pointer-events: none;
border: solid transparent;
content: '';
height: 0;
width: 0;
left: 22px;
}
.flatpickr-calendar.rightMost:before,
.flatpickr-calendar.arrowRight:before,
.flatpickr-calendar.rightMost:after,
.flatpickr-calendar.arrowRight:after {
left: auto;
right: 22px;
}
.flatpickr-calendar.arrowCenter:before,
.flatpickr-calendar.arrowCenter:after {
left: 50%;
right: 50%;
}
.flatpickr-calendar:before {
border-width: 5px;
margin: 0 -5px;
}
.flatpickr-calendar:after {
border-width: 4px;
margin: 0 -4px;
}
.flatpickr-calendar.arrowTop:before,
.flatpickr-calendar.arrowTop:after {
bottom: 100%;
}
.flatpickr-calendar.arrowTop:before {
border-bottom-color: #eee;
}
.flatpickr-calendar.arrowTop:after {
border-bottom-color: #fff;
}
.flatpickr-calendar.arrowBottom:before,
.flatpickr-calendar.arrowBottom:after {
top: 100%;
}
.flatpickr-calendar.arrowBottom:before {
border-top-color: #eee;
}
.flatpickr-calendar.arrowBottom:after {
border-top-color: #fff;
}
.flatpickr-calendar:focus {
outline: 0;
}
.flatpickr-wrapper {
position: relative;
display: inline-block;
}
.flatpickr-months {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.flatpickr-months .flatpickr-month {
background: transparent;
color: #3c3f40;
fill: #3c3f40;
height: 34px;
line-height: 1;
text-align: center;
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
overflow: hidden;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
text-decoration: none;
cursor: pointer;
position: absolute;
top: 0;
height: 34px;
padding: 10px;
z-index: 3;
color: #3c3f40;
fill: #3c3f40;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
display: none;
}
.flatpickr-months .flatpickr-prev-month i,
.flatpickr-months .flatpickr-next-month i {
position: relative;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
/*
/*rtl:begin:ignore*/
/*
*/
left: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
/*
/*rtl:begin:ignore*/
/*
*/
right: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month:hover,
.flatpickr-months .flatpickr-next-month:hover {
color: #f64747;
}
.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
fill: #f64747;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
width: 14px;
height: 14px;
}
.flatpickr-months .flatpickr-prev-month svg path,
.flatpickr-months .flatpickr-next-month svg path {
-webkit-transition: fill 0.1s;
transition: fill 0.1s;
fill: inherit;
}
.numInputWrapper {
position: relative;
height: auto;
}
.numInputWrapper input,
.numInputWrapper span {
display: inline-block;
}
.numInputWrapper input {
width: 100%;
}
.numInputWrapper input::-ms-clear {
display: none;
}
.numInputWrapper input::-webkit-outer-spin-button,
.numInputWrapper input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.numInputWrapper span {
position: absolute;
right: 0;
width: 14px;
padding: 0 4px 0 2px;
height: 50%;
line-height: 50%;
opacity: 0;
cursor: pointer;
border: 1px solid rgba(64,72,72,0.15);
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.numInputWrapper span:hover {
background: rgba(0,0,0,0.1);
}
.numInputWrapper span:active {
background: rgba(0,0,0,0.2);
}
.numInputWrapper span:after {
display: block;
content: "";
position: absolute;
}
.numInputWrapper span.arrowUp {
top: 0;
border-bottom: 0;
}
.numInputWrapper span.arrowUp:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid rgba(64,72,72,0.6);
top: 26%;
}
.numInputWrapper span.arrowDown {
top: 50%;
}
.numInputWrapper span.arrowDown:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid rgba(64,72,72,0.6);
top: 40%;
}
.numInputWrapper span svg {
width: inherit;
height: auto;
}
.numInputWrapper span svg path {
fill: rgba(60,63,64,0.5);
}
.numInputWrapper:hover {
background: rgba(0,0,0,0.05);
}
.numInputWrapper:hover span {
opacity: 1;
}
.flatpickr-current-month {
font-size: 135%;
line-height: inherit;
font-weight: 300;
color: inherit;
position: absolute;
width: 75%;
left: 12.5%;
padding: 7.48px 0 0 0;
line-height: 1;
height: 34px;
display: inline-block;
text-align: center;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
}
.flatpickr-current-month span.cur-month {
font-family: inherit;
font-weight: 700;
color: inherit;
display: inline-block;
margin-left: 0.5ch;
padding: 0;
}
.flatpickr-current-month span.cur-month:hover {
background: rgba(0,0,0,0.05);
}
.flatpickr-current-month .numInputWrapper {
width: 6ch;
width: 7ch\0;
display: inline-block;
}
.flatpickr-current-month .numInputWrapper span.arrowUp:after {
border-bottom-color: #3c3f40;
}
.flatpickr-current-month .numInputWrapper span.arrowDown:after {
border-top-color: #3c3f40;
}
.flatpickr-current-month input.cur-year {
background: transparent;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: inherit;
cursor: text;
padding: 0 0 0 0.5ch;
margin: 0;
display: inline-block;
font-size: inherit;
font-family: inherit;
font-weight: 300;
line-height: inherit;
height: auto;
border: 0;
border-radius: 0;
vertical-align: initial;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-current-month input.cur-year:focus {
outline: 0;
}
.flatpickr-current-month input.cur-year[disabled],
.flatpickr-current-month input.cur-year[disabled]:hover {
font-size: 100%;
color: rgba(60,63,64,0.5);
background: transparent;
pointer-events: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
appearance: menulist;
background: transparent;
border: none;
border-radius: 0;
box-sizing: border-box;
color: inherit;
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 300;
height: auto;
line-height: inherit;
margin: -1px 0 0 0;
outline: none;
padding: 0 0 0 0.5ch;
position: relative;
vertical-align: initial;
-webkit-box-sizing: border-box;
-webkit-appearance: menulist;
-moz-appearance: menulist;
width: auto;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
.flatpickr-current-month .flatpickr-monthDropdown-months:active {
outline: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
background: rgba(0,0,0,0.05);
}
.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
background-color: transparent;
outline: none;
padding: 0;
}
.flatpickr-weekdays {
background: transparent;
text-align: center;
overflow: hidden;
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
height: 28px;
}
.flatpickr-weekdays .flatpickr-weekdaycontainer {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
span.flatpickr-weekday {
cursor: default;
font-size: 90%;
background: transparent;
color: rgba(0,0,0,0.54);
line-height: 1;
margin: 0;
text-align: center;
display: block;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
font-weight: bolder;
}
.dayContainer,
.flatpickr-weeks {
padding: 1px 0 0 0;
}
.flatpickr-days {
position: relative;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: start;
-webkit-align-items: flex-start;
-ms-flex-align: start;
align-items: flex-start;
width: 307.875px;
}
.flatpickr-days:focus {
outline: 0;
}
.dayContainer {
padding: 0;
outline: 0;
text-align: left;
width: 307.875px;
min-width: 307.875px;
max-width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
display: inline-block;
display: -ms-flexbox;
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-flex-wrap: wrap;
flex-wrap: wrap;
-ms-flex-wrap: wrap;
-ms-flex-pack: justify;
-webkit-justify-content: space-around;
justify-content: space-around;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
opacity: 1;
}
.dayContainer + .dayContainer {
-webkit-box-shadow: -1px 0 0 #eee;
box-shadow: -1px 0 0 #eee;
}
.flatpickr-day {
background: none;
border: 1px solid transparent;
border-radius: 150px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #404848;
cursor: pointer;
font-weight: 400;
width: 14.2857143%;
-webkit-flex-basis: 14.2857143%;
-ms-flex-preferred-size: 14.2857143%;
flex-basis: 14.2857143%;
max-width: 39px;
height: 39px;
line-height: 39px;
margin: 0;
display: inline-block;
position: relative;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
}
.flatpickr-day.inRange,
.flatpickr-day.prevMonthDay.inRange,
.flatpickr-day.nextMonthDay.inRange,
.flatpickr-day.today.inRange,
.flatpickr-day.prevMonthDay.today.inRange,
.flatpickr-day.nextMonthDay.today.inRange,
.flatpickr-day:hover,
.flatpickr-day.prevMonthDay:hover,
.flatpickr-day.nextMonthDay:hover,
.flatpickr-day:focus,
.flatpickr-day.prevMonthDay:focus,
.flatpickr-day.nextMonthDay:focus {
cursor: pointer;
outline: 0;
background: #e9e9e9;
border-color: #e9e9e9;
}
.flatpickr-day.today {
border-color: #f64747;
}
.flatpickr-day.today:hover,
.flatpickr-day.today:focus {
border-color: #f64747;
background: #f64747;
color: #fff;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
.flatpickr-day.selected.inRange,
.flatpickr-day.startRange.inRange,
.flatpickr-day.endRange.inRange,
.flatpickr-day.selected:focus,
.flatpickr-day.startRange:focus,
.flatpickr-day.endRange:focus,
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover,
.flatpickr-day.selected.prevMonthDay,
.flatpickr-day.startRange.prevMonthDay,
.flatpickr-day.endRange.prevMonthDay,
.flatpickr-day.selected.nextMonthDay,
.flatpickr-day.startRange.nextMonthDay,
.flatpickr-day.endRange.nextMonthDay {
background: #4f99ff;
-webkit-box-shadow: none;
box-shadow: none;
color: #fff;
border-color: #4f99ff;
}
.flatpickr-day.selected.startRange,
.flatpickr-day.startRange.startRange,
.flatpickr-day.endRange.startRange {
border-radius: 50px 0 0 50px;
}
.flatpickr-day.selected.endRange,
.flatpickr-day.startRange.endRange,
.flatpickr-day.endRange.endRange {
border-radius: 0 50px 50px 0;
}
.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
-webkit-box-shadow: -10px 0 0 #4f99ff;
box-shadow: -10px 0 0 #4f99ff;
}
.flatpickr-day.selected.startRange.endRange,
.flatpickr-day.startRange.startRange.endRange,
.flatpickr-day.endRange.startRange.endRange {
border-radius: 50px;
}
.flatpickr-day.inRange {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #e9e9e9, 5px 0 0 #e9e9e9;
box-shadow: -5px 0 0 #e9e9e9, 5px 0 0 #e9e9e9;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover,
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay,
.flatpickr-day.notAllowed,
.flatpickr-day.notAllowed.prevMonthDay,
.flatpickr-day.notAllowed.nextMonthDay {
color: rgba(64,72,72,0.3);
background: transparent;
border-color: #e9e9e9;
cursor: default;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
cursor: not-allowed;
color: rgba(64,72,72,0.1);
}
.flatpickr-day.week.selected {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #4f99ff, 5px 0 0 #4f99ff;
box-shadow: -5px 0 0 #4f99ff, 5px 0 0 #4f99ff;
}
.flatpickr-day.hidden {
visibility: hidden;
}
.rangeMode .flatpickr-day {
margin-top: 1px;
}
.flatpickr-weekwrapper {
float: left;
}
.flatpickr-weekwrapper .flatpickr-weeks {
padding: 0 12px;
-webkit-box-shadow: 1px 0 0 #eee;
box-shadow: 1px 0 0 #eee;
}
.flatpickr-weekwrapper .flatpickr-weekday {
float: none;
width: 100%;
line-height: 28px;
}
.flatpickr-weekwrapper span.flatpickr-day,
.flatpickr-weekwrapper span.flatpickr-day:hover {
display: block;
width: 100%;
max-width: none;
color: rgba(64,72,72,0.3);
background: transparent;
cursor: default;
border: none;
}
.flatpickr-innerContainer {
display: block;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
}
.flatpickr-rContainer {
display: inline-block;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.flatpickr-time {
text-align: center;
outline: 0;
display: block;
height: 0;
line-height: 40px;
max-height: 40px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.flatpickr-time:after {
content: "";
display: table;
clear: both;
}
.flatpickr-time .numInputWrapper {
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
width: 40%;
height: 40px;
float: left;
}
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #404848;
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #404848;
}
.flatpickr-time.hasSeconds .numInputWrapper {
width: 26%;
}
.flatpickr-time.time24hr .numInputWrapper {
width: 49%;
}
.flatpickr-time input {
background: transparent;
-webkit-box-shadow: none;
box-shadow: none;
border: 0;
border-radius: 0;
text-align: center;
margin: 0;
padding: 0;
height: inherit;
line-height: inherit;
color: #404848;
font-size: 14px;
position: relative;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-time input.flatpickr-hour {
font-weight: bold;
}
.flatpickr-time input.flatpickr-minute,
.flatpickr-time input.flatpickr-second {
font-weight: 400;
}
.flatpickr-time input:focus {
outline: 0;
border: 0;
}
.flatpickr-time .flatpickr-time-separator,
.flatpickr-time .flatpickr-am-pm {
height: inherit;
float: left;
line-height: inherit;
color: #404848;
font-weight: bold;
width: 2%;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-align-self: center;
-ms-flex-item-align: center;
align-self: center;
}
.flatpickr-time .flatpickr-am-pm {
outline: 0;
width: 18%;
cursor: pointer;
text-align: center;
font-weight: 400;
}
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: #f1f1f1;
}
.flatpickr-input[readonly] {
cursor: pointer;
}
@-webkit-keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
.flatpickr-calendar {
width: 307.875px;
}
.dayContainer {
padding: 0;
border-right: 0;
}
span.flatpickr-day,
span.flatpickr-day.prevMonthDay,
span.flatpickr-day.nextMonthDay {
border-radius: 0 !important;
border: 1px solid #e9e9e9;
max-width: none;
border-right-color: transparent;
}
span.flatpickr-day:nth-child(n+8),
span.flatpickr-day.prevMonthDay:nth-child(n+8),
span.flatpickr-day.nextMonthDay:nth-child(n+8) {
border-top-color: transparent;
}
span.flatpickr-day:nth-child(7n-6),
span.flatpickr-day.prevMonthDay:nth-child(7n-6),
span.flatpickr-day.nextMonthDay:nth-child(7n-6) {
border-left: 0;
}
span.flatpickr-day:nth-child(n+36),
span.flatpickr-day.prevMonthDay:nth-child(n+36),
span.flatpickr-day.nextMonthDay:nth-child(n+36) {
border-bottom: 0;
}
span.flatpickr-day:nth-child(-n+7),
span.flatpickr-day.prevMonthDay:nth-child(-n+7),
span.flatpickr-day.nextMonthDay:nth-child(-n+7) {
margin-top: 0;
}
span.flatpickr-day.today:not(.selected),
span.flatpickr-day.prevMonthDay.today:not(.selected),
span.flatpickr-day.nextMonthDay.today:not(.selected) {
border-color: #e9e9e9;
border-right-color: transparent;
border-top-color: transparent;
border-bottom-color: #f64747;
}
span.flatpickr-day.today:not(.selected):hover,
span.flatpickr-day.prevMonthDay.today:not(.selected):hover,
span.flatpickr-day.nextMonthDay.today:not(.selected):hover {
border: 1px solid #f64747;
}
span.flatpickr-day.startRange,
span.flatpickr-day.prevMonthDay.startRange,
span.flatpickr-day.nextMonthDay.startRange,
span.flatpickr-day.endRange,
span.flatpickr-day.prevMonthDay.endRange,
span.flatpickr-day.nextMonthDay.endRange {
border-color: #4f99ff;
}
span.flatpickr-day.today,
span.flatpickr-day.prevMonthDay.today,
span.flatpickr-day.nextMonthDay.today,
span.flatpickr-day.selected,
span.flatpickr-day.prevMonthDay.selected,
span.flatpickr-day.nextMonthDay.selected {
z-index: 2;
}
.rangeMode .flatpickr-day {
margin-top: -1px;
}
.flatpickr-weekwrapper .flatpickr-weeks {
-webkit-box-shadow: none;
box-shadow: none;
}
.flatpickr-weekwrapper span.flatpickr-day {
border: 0;
margin: -1px 0 0 -1px;
}
.hasWeeks .flatpickr-days {
border-right: 0;
}
@media screen and (min-width:0\0) and (min-resolution: +72dpi) {
span.flatpickr-day {
display: block;
-webkit-box-flex: 1;
-webkit-flex: 1 0 auto;
-ms-flex: 1 0 auto;
flex: 1 0 auto;
}
}

View file

@ -0,0 +1,185 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden" id="bans_number" value="{{ bans|length }}" />
<input type="hidden"
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<table id="bans" class="table position-relative 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 date and time when the Ban was created">Date</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The banned IP address">IP address</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The reason why the Report was created">Reason</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The end date of the Ban">End date</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The time left until the Ban expires">Time left</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The actions that can be performed on the Ban">Actions</th>
</tr>
</thead>
<tbody>
{% for ban in bans %}
<tr>
<td></td>
<td class="ban-start-date">{{ ban["start_date"] }}</td>
<td>{{ ban["ip"] }}</td>
<td>{{ ban["reason"] }}</td>
<td class="ban-end-date">{{ ban["end_date"] }}</td>
<td>{{ ban["remain"] }}</td>
<td>
<div class="d-flex justify-content-center">
<div data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Unban {{ ban['ip'] }}">
<button type="button"
data-ip="{{ ban['ip'] }}"
data-time-left="{{ ban['remain'] }}"
class="btn btn-outline-danger btn-sm me-1 unban-ip">
<i class="bx bxs-buoy bx-xs"></i>
</button>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
<span class="position-absolute bottom-0 start-50 translate-middle badge rounded-pill bg-secondary">
TZ: <script nonce="{{ script_nonce }}">document.write(Intl.DateTimeFormat().resolvedOptions().timeZone);</script>
</span>
</table>
</div>
<div class="modal modal-xl fade"
id="modal-ban-ips"
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">Add Ban(s)</h5>
<button id="add-ban"
type="button"
class="btn btn-text-bw-green rounded-pill p-0 ms-4 me-2">
<i class="bx bx-plus-circle"></i>&nbsp;INSERT
</button>
<button id="clear-bans"
type="button"
class="btn btn-text-danger rounded-pill p-0 ms-4">
<i class="bx bx-trash"></i>&nbsp;CLEAR
</button>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<form id="bans-form" action="{{ url_for("bans") }}/ban" method="POST">
<div class="modal-body justify-content-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<ul id="bans-container" class="list-group rounded-top w-100">
<li id="bans-header" class="list-group-item bg-secondary text-white">
<div class="row">
<div class="col-3 text-center fw-bold">IP Address</div>
<div class="col-5 border-start text-center fw-bold">End Date</div>
<div class="col-3 border-start text-center fw-bold">Reason</div>
<div class="col-1 border-start text-center fw-bold">Delete</div>
</div>
</li>
<li id="ban-1" class="list-group-item rounded-0">
<div class="row align-items-center d-flex">
<div class="col-3">
<input type="text"
name="ip"
class="form-control"
placeholder="127.0.0.1"
required />
</div>
<div class="col-5 border-start">
<input type="flapickr-datetime"
name="datetime"
class="form-control"
required />
</div>
<div class="col-3 border-start">
<input type="text" name="reason" class="form-control" value="ui" required />
</div>
<div class="col-1 border-start align-items-center d-flex"
data-bs-toggle="tooltip"
data-bs-placement="right"
data-bs-original-title="Can't delete the original Ban">
<button type="button"
class="btn btn-outline-danger btn-sm me-1 delete-ban disabled">
<i class="bx bx-trash bx-xs"></i>
</button>
</div>
</div>
</li>
</ul>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-danger me-2">Ban</button>
<button type="reset"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<div class="modal modal-lg fade"
id="modal-unban-ips"
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 Unban</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<form action="{{ url_for("bans") }}/unban" method="POST">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" id="selected-ips-input-unban" name="ips" value="" />
<div class="alert alert-danger text-center" role="alert">
Are you sure you want to unban the selected IP addresses?
</div>
<ul id="selected-ips-unban" class="list-group w-100 mb-3">
</ul>
</div>
<div class="modal-footer justify-content-center">
<button type="submit" class="btn btn-outline-danger me-2">Unban</button>
<button type="reset"
class="btn btn-outline-secondary"
data-bs-dismiss="modal">Cancel</button>
</div>
</form>
</div>
</div>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -25,12 +25,21 @@
<link rel="stylesheet"
href="{{ url_for('static', filename='fonts/boxicons.min.css') }}"
nonce="{{ style_nonce }}" />
{% if current_endpoint in ("instances", "services", "configs", "cache") %}
{% if current_endpoint in ("instances", "services", "configs", "cache", "reports", "bans", "jobs") %}
<!-- Datatables -->
<link rel="stylesheet"
href="{{ url_for('static', filename='libs/datatables/datatables.min.css') }}"
nonce="{{ style_nonce }}" />
{% endif %}
{% if current_endpoint in ("bans") %}
<!-- Flatpickr -->
<link rel="stylesheet"
href="{{ url_for('static', filename='libs/flatpickr/flatpickr.min.css') }}"
nonce="{{ style_nonce }}" />
<link rel="stylesheet"
href="{{ url_for('static', filename='libs/flatpickr/themes/airbnb.css') }}"
nonce="{{ style_nonce }}" />
{% endif %}
<!-- Core CSS -->
<link rel="stylesheet"
href="{{ url_for('static', filename='css/core.css') }}"
@ -89,11 +98,17 @@
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", "configs", "cache") %}
{% if current_endpoint in ("instances", "services", "configs", "cache", "reports", "bans", "jobs") %}
<script src="{{ url_for('static', filename='libs/datatables/datatables.min.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
{% if current_endpoint not in ("services", "configs", "cache") and ("services" in request.path or "configs" in request.path or "cache" in request.path) %}
{% if current_endpoint in ("bans") %}
<script src="{{ url_for('static', filename='libs/flatpickr/flatpickr.min.js') }}"
nonce="{{ script_nonce }}"></script>
<script src="{{ url_for('static', filename='libs/flatpickr/plugins/minMaxTimePlugin.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
{% if current_endpoint not in ("services", "configs", "cache") and ("services" in request.path or "configs" in request.path or "cache" in request.path or current_endpoint == "logs") %}
<script src="{{ url_for('static', filename='libs/ace/src-min/ace.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
@ -143,6 +158,18 @@
{% elif current_endpoint != "cache" and "cache" in request.path %}
<script src="{{ url_for('static', filename='js/pages/cache_view.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint == "reports" %}
<script src="{{ url_for('static', filename='js/pages/reports.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint == "bans" %}
<script src="{{ url_for('static', filename='js/pages/bans.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint == "jobs" %}
<script src="{{ url_for('static', filename='js/pages/jobs.js') }}"
nonce="{{ script_nonce }}"></script>
{% elif current_endpoint == "logs" %}
<script src="{{ url_for('static', filename='js/pages/logs.js') }}"
nonce="{{ script_nonce }}"></script>
{% endif %}
<script async defer src="{{ url_for('static', filename='js/buttons.js') }}"></script>
</body>

View file

@ -7,30 +7,32 @@
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<table id="cache" class="table w-100">
<table id="cache" class="table position-relative 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>
data-bs-original-title="The Cache file's 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>
data-bs-original-title="The Cache file'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>
data-bs-original-title="The Cache file'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>
data-bs-original-title="The Service associated with the Cache file">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>
data-bs-original-title="The date and time when the Cache file 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>
data-bs-original-title="The Cache file'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>
data-bs-original-title="The actions that can be performed on the Cache file">Actions</th>
</tr>
</thead>
<tbody>
@ -38,7 +40,7 @@
{% 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('/', '_') }}"
<a href="{{ url_for("cache") }}/{{ service_id }}/{{ cache['plugin_id'] }}/{{ cache['job_name'] }}/{{ cache['file_name'].replace('/', '_') if cache['file_name'].startswith('folder:') else cache['file_name'] }}"
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>
@ -55,23 +57,23 @@
<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 class="cache-last-update-date">
{{ cache["last_update"] if cache["last_update"] == "Never" else cache["last_update"].astimezone().isoformat() }}
</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('/', '_') }}"
href="{{ url_for("cache") }}/{{ service_id }}/{{ cache['plugin_id'] }}/{{ cache['job_name'] }}/{{ cache['file_name'].replace('/', '_') if cache['file_name'].startswith('folder:') else cache['file_name'] }}"
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"
class="btn btn-outline-secondary btn-sm"
href="{{ url_for("cache") }}/{{ service_id }}/{{ cache['plugin_id'] }}/{{ cache['job_name'] }}/{{ cache['file_name'].replace('/', '_') if cache['file_name'].startswith('folder:') else cache['file_name'] }}?download=true"
data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Download {{ cache['file_name'] }}"
@ -84,6 +86,9 @@
</tr>
{% endfor %}
</tbody>
<span class="position-absolute bottom-0 start-50 translate-middle badge rounded-pill bg-secondary">
TZ: <script nonce="{{ script_nonce }}">document.write(Intl.DateTimeFormat().resolvedOptions().timeZone);</script>
</span>
</table>
</div>
<!-- / Content -->

View file

@ -2,11 +2,11 @@
{% 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">
<div class="position-absolute bottom-0 end-0 m-4" style="z-index: 1000">
<div class="card p-1">
<a role="button"
class="btn btn-sm btn-outline-secondary "
href="{{ url_for("cache") }}/{{ '/'.join(request.path.split('/')[2:]) }}?download=true"
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"

View file

@ -134,7 +134,7 @@
{% 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-method="{{ config_method or 'ui' }}"
data-template="{{ config_template }}"
class="ace-editor border rounded position-absolute top-0 start-0 end-0 bottom-0">{{ config_value }}</div>
</div>

View file

@ -39,7 +39,7 @@
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>
data-bs-original-title="The actions that can be performed on the Custom config">Actions</th>
</tr>
</thead>
<tbody>

View file

@ -23,7 +23,7 @@
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<table id="instances" class="table w-100">
<table id="instances" class="table position-relative w-100">
<thead>
<tr>
<th>
@ -87,8 +87,8 @@
<i class="bx bx-microchip"></i>&nbsp;Static
{% endif %}
</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 class="instance-creation-date">{{ instance.creation_date.astimezone().isoformat() }}</td>
<td class="instance-last-seen-date">{{ instance.last_seen.astimezone().isoformat() }}</td>
<td>
<div class="d-flex justify-content-center">
<button type="button"
@ -131,6 +131,9 @@
</tr>
{% endfor %}
</tbody>
<span class="position-absolute bottom-0 start-50 translate-middle badge rounded-pill bg-secondary">
TZ: <script nonce="{{ script_nonce }}">document.write(Intl.DateTimeFormat().resolvedOptions().timeZone);</script>
</span>
</table>
</div>
<div id="feedback-toast"

View file

@ -0,0 +1,145 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden" id="job_number" value="{{ jobs|length }}" />
<table id="jobs" class="table position-relative w-100">
<thead>
<tr>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Job's name">Name</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Job's Plugin">Plugin</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Job's interval">Interval</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Does the Job needs a reload?">Reload</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Does the last Job's execution was successful?">Last run</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The Job's information">Information</th>
</tr>
</thead>
<tbody>
{% for job, job_data in jobs.items() %}
<tr>
<td>{{ job }}</td>
<td>{{ job_data["plugin_id"] }}</td>
<td>
<i class="bx {% if job_data['every'] == 'once' %}bx-check-square{% elif job_data['every'] == 'day' %}bx-calendar-event{% elif job_data['every'] == 'week' %}bx-calendar-week{% else %}bxs-hourglass{% endif %}"></i>&nbsp;{{ job_data["every"] }}
</td>
<td class="text-center">
<i class="bx bx-sm bx-{% if job_data['reload'] %}check-circle text-success{% else %}x-circle text-danger{% endif %}"></i>
</td>
<td class="text-center">
{% if job_data['history'] %}
<i class="bx bx-sm bx-{% if job_data['history'][0]['success'] %}check-circle text-success{% else %}x-circle text-danger{% endif %}"></i>
{% else %}
No history
{% endif %}
</td>
<td>
<div class="d-flex justify-content-center">
{% if job_data['history'] %}
<div id="job-{{ job }}-{{ job_data['plugin_id'] }}-history"
class="visually-hidden mb-3">
<ul class="list-group list-group-horizontal d-flex w-100">
<li class="list-group-item align-items-center text-center bg-secondary text-white rounded-0 rounded-top"
style="flex: 1 1 0">
<div class="ms-2 me-auto">
<div class="fw-bold">Start date</div>
</div>
</li>
<li class="list-group-item align-items-center text-center bg-secondary text-white rounded-0 rounded-top"
style="flex: 1 1 0">
<div class="fw-bold">End date</div>
</li>
<li class="list-group-item align-items-center text-center bg-secondary text-white rounded-0 rounded-top"
style="flex: 1 1 0">
<div class="fw-bold">Success</div>
</li>
</ul>
{% for history in job_data['history'] %}
<ul class="list-group list-group-horizontal d-flex w-100">
<li class="list-group-item align-items-center history-start-date rounded-0{% if loop.index == job_data['history']|length %} rounded-bottom{% endif %}"
style="flex: 1 1 0">{{ history['start_date'] }}</li>
<li class="list-group-item align-items-center history-end-date rounded-0{% if loop.index == job_data['history']|length %} rounded-bottom{% endif %}"
style="flex: 1 1 0">{{ history['end_date'] }}</li>
<li class="list-group-item align-items-center text-center rounded-0{% if loop.index == job_data['history']|length %} rounded-bottom{% endif %}" style="flex: 1 1 0">
<i class="bx bx-{% if history['success'] %}check-circle text-success{% else %}x-circle text-danger{% endif %}"></i>
</li>
</ul>
{% endfor %}
</div>
{% endif %}
<div {% if not job_data['history'] %}data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-original-title="No history"{% endif %}>
<button type="button"
data-job="{{ job }}"
data-plugin="{{ job_data['plugin_id'] }}"
class="btn btn-outline-primary btn-sm me-1 show-history{% if not job_data['history'] %} disabled{% endif %}">
<i class="bx bx-history bx-xs"></i>&nbsp;History
</button>
</div>
<div class="dropdown btn-group"
{% if not job_data['cache'] %}data-bs-toggle="tooltip" data-bs-placement="bottom" data-bs-original-title="No cache"{% endif %}>
<button type="button"
class="btn btn-outline-secondary btn-sm dropdown-toggle{% if not job_data['cache'] %} disabled{% endif %}"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
<i class="bx bx-data"></i>
<span class="d-none d-md-inline">&nbsp;Cache</span>
</button>
<ul class="dropdown-menu nav-pills max-vh-60 overflow-auto pt-0"
role="tablist">
{% for cache in job_data['cache'] %}
{% set service_id = cache['service_id'] if cache['service_id'] else 'global' %}
<li class="nav-item">
<a role="button"
class="dropdown-item"
href="{{ url_for("cache") }}/{{ service_id }}/{{ job_data['plugin_id'] }}/{{ job }}/{{ cache['file_name'].replace('/', '_') if cache['file_name'].startswith('folder:') else cache['file_name'] }}?download=true"
target="_blank"
rel="noopener noreferrer">
<i class="tf-icons bx bx-download bx-xs me-1"></i>{{ cache["file_name"] }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
<span class="position-absolute bottom-0 start-50 translate-middle badge rounded-pill bg-secondary">
TZ: <script nonce="{{ script_nonce }}">document.write(Intl.DateTimeFormat().resolvedOptions().timeZone);</script>
</span>
</table>
</div>
<div class="modal modal-lg fade"
id="modal-job-history"
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">Job History</h5>
<button type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body"></div>
</div>
</div>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -0,0 +1,72 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<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 justify-content-center">
<div class="dropdown btn-group"
{% if not files %}data-bs-toggle="tooltip" data-bs-placement="top" title="No log files available"{% endif %}>
<button type="button"
class="btn btn-outline-primary dropdown-toggle{% if not files %} disabled{% endif %}"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
<i class="bx bx-file"></i>
<span class="d-none d-md-inline">&nbsp;Select File</span>
</button>
<ul id="types-dropdown-menu"
class="dropdown-menu nav-pills max-vh-60 overflow-auto pt-0"
role="tablist">
{% for file in files %}
<li class="nav-item">
<a role="button"
href="{{ url_for('logs', file=file) }}"
class="dropdown-item{% if current_file and current_file == file %} active{% endif %}">
{{ file }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% if page_num > 1 %}
<div class="dropdown btn-group">
<button type="button"
class="btn btn-outline-primary dropdown-toggle ms-2"
data-bs-toggle="dropdown"
aria-haspopup="true"
aria-expanded="false">
<i class="bx bx-file-find"></i>
<span class="d-none d-md-inline">&nbsp;Select Page</span>
</button>
<ul id="pages-dropdown-menu"
class="dropdown-menu nav-pills max-vh-60 overflow-auto pt-0"
role="tablist">
{% for page in range(page_num, 0, -1) %}
<li class="nav-item">
<a role="button"
href="{% if loop.index == 1 %}{{ url_for('logs', file=current_file) }}{% else %}{{ url_for('logs', file=current_file, page=page) }}{% endif %}"
class="dropdown-item{% if current_page == page %} active{% endif %}">
Page {{ page }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<div {% if not request.is_secure %}data-bs-toggle="tooltip" data-bs-placement="top" title="The copy feature is only available over HTTPS"{% endif %}>
<button id="copy-logs"
type="button"
class="btn btn-outline-secondary {% if not request.is_secure %}disabled{% endif %}">
<i class="bx bx-copy-alt"></i>
<span class="d-none d-md-inline">&nbsp;Copy</span>
</button>
</div>
</div>
</div>
<div class="card p-4 min-vh-70">
<div id="raw-logs"
class="ace-editor border rounded position-absolute top-0 start-0 end-0 bottom-0">{{ logs }}</div>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -24,7 +24,7 @@
"configs": {"url": url_for('configs'), "icon": "bx-wrench"},
"plugins": {"url": url_for('plugins'), "icon": "bx-plug"},
"cache": {"url": url_for('cache'), "icon": "bx-data"},
"reports": {"url": url_for('reports'), "icon": "bx-bar-chart-alt-2"},
"reports": {"url": url_for('reports'), "icon": "bxs-flag-checkered"},
"bans": {"url": url_for('bans'), "icon": "bx-block"},
"jobs": {"url": url_for('jobs'), "icon": "bx-time-five"},
"logs": {"url": url_for('logs'), "icon": "bx-file-find"},

View file

@ -4,14 +4,12 @@
value="{{ csrf_token() }}">
<div class="position-absolute top-0 end-0 m-3" style="z-index: 1000">
<div class="d-flex flex-wrap justify-content-center align-items-center">
{% if request.is_secure %}
<div class="card p-1 me-2">
<button type="button" class="btn btn-sm btn-outline-secondary copy-settings">
<i class="bx bx-copy-alt bx-xs"></i>
<span class="d-none d-md-inline">&nbsp;Copy</span>
</button>
</div>
{% endif %}
<div class="card p-1 me-2" {% if not request.is_secure %}data-bs-toggle="tooltip" data-bs-placement="top" title="The copy feature is only available over HTTPS"{% endif %}>
<button type="button" class="btn btn-sm btn-outline-secondary copy-settings{% if not request.is_secure %} disabled{% endif %}">
<i class="bx bx-copy-alt bx-xs"></i>
<span class="d-none d-md-inline">&nbsp;Copy</span>
</button>
</div>
<div class="card p-1"
{% if service_method == "autoconf" %} data-bs-toggle="tooltip" data-bs-placement="top" title="The service was created using the autoconf method, therefore the configuration is locked"{% endif %}>
<!-- Save button container -->

View file

@ -0,0 +1,59 @@
{% extends "dashboard.html" %}
{% block content %}
<!-- Content -->
<div class="card table-responsive text-nowrap p-4 min-vh-70">
<input type="hidden" id="reports_number" value="{{ reports|length }}" />
<table id="reports" class="table position-relative w-100">
<thead>
<tr>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The date and time when the Report was created">Date</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The reported IP address">IP Address</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The country of the reported IP address">Country</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The method used by the attacker">Method</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The URL that was targeted by the attacker">URL</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The HTTP status code returned by BunkerWeb">Status Code</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The User-Agent of the attacker">User-Agent</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="The reason why the Report was created">Reason</th>
<th data-bs-toggle="tooltip"
data-bs-placement="bottom"
data-bs-original-title="Additional data about the Report">Data</th>
</tr>
</thead>
<tbody>
{% for report in reports %}
<tr>
<td class="report-date">{{ report["date"] }}</td>
<td>{{ report["ip"] }}</td>
<td>{{ report["country"] }}</td>
<td>{{ report["method"] }}</td>
<td>{{ report["url"] }}</td>
<td>{{ report["status"] }}</td>
<td>{{ report["user_agent"] }}</td>
<td>{{ report["reason"] }}</td>
<td>{{ report["data"] }}</td>
</tr>
{% endfor %}
</tbody>
<span class="position-absolute bottom-0 start-50 translate-middle badge rounded-pill bg-secondary">
TZ: <script nonce="{{ script_nonce }}">document.write(Intl.DateTimeFormat().resolvedOptions().timeZone);</script>
</span>
</table>
</div>
<!-- / Content -->
{% endblock %}

View file

@ -7,7 +7,7 @@
id="csrf_token"
name="csrf_token"
value="{{ csrf_token() }}" />
<table id="services" class="table w-100">
<table id="services" class="table position-relative w-100">
<thead>
<tr>
<th>
@ -59,8 +59,8 @@
{% 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 class="service-creation-date">{{ service['creation_date'].astimezone().isoformat() }}</td>
<td class="service-last-update-date">{{ service['last_update'].astimezone().isoformat() }}</td>
<td>
<div class="d-flex justify-content-center">
<a role="button"
@ -112,6 +112,9 @@
</tr>
{% endfor %}
</tbody>
<span class="position-absolute bottom-0 start-50 translate-middle badge rounded-pill bg-secondary">
TZ: <script nonce="{{ script_nonce }}">document.write(Intl.DateTimeFormat().resolvedOptions().timeZone);</script>
</span>
</table>
</div>
<div id="feedback-toast"

View file

@ -147,7 +147,7 @@ def inject_variables():
flash("The last changes have been applied successfully.", "success")
DATA["CONFIG_CHANGED"] = False
services = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ")
services = BW_CONFIG.get_config(global_only=True, with_drafts=True, methods=False, filtered_settings=("SERVER_NAME"))["SERVER_NAME"].split(" ")
# check that is value is in tuple
return dict(