diff --git a/CHANGELOG.md b/CHANGELOG.md
index 266ec6bf..f4b0a708 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
## --- [4.2.0] - 2023/TBD
### New features
- Finish and Activate Arcadia notification backend ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/621) | [Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/626) | [Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/632))
+- Add initial Webhook Notification (Discord, Mattermost, Slack, Teams) ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/594))
### Bug fixes
- PWA: Removed the custom offline page in favour of browser default ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/607))
- Fix hidden servers appearing visible on public mobile status page ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/612))
@@ -23,6 +24,7 @@
- Make files hover cursor pointer ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/627))
- Use `Jar` class naming for jar refresh to make room for steamCMD naming in the future ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/630))
- Improve ui visibility of Build Wizard selection tabs ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/633))
+- Add additional logging for server bootstrap & moves unnecessary logging to `debug` for improved log clarity ([Merge Request](https://gitlab.com/crafty-controller/crafty-4/-/merge_requests/635))
### Lang
TBD
diff --git a/app/classes/controllers/management_controller.py b/app/classes/controllers/management_controller.py
index cda74fea..25c2a7f2 100644
--- a/app/classes/controllers/management_controller.py
+++ b/app/classes/controllers/management_controller.py
@@ -1,7 +1,7 @@
import logging
import queue
-from app.classes.models.management import HelpersManagement
+from app.classes.models.management import HelpersManagement, HelpersWebhooks
from app.classes.models.servers import HelperServers
logger = logging.getLogger(__name__)
@@ -206,3 +206,30 @@ class ManagementController:
@staticmethod
def set_master_server_dir(server_dir):
HelpersManagement.set_master_server_dir(server_dir)
+
+ # **********************************************************************************
+ # Webhooks Methods
+ # **********************************************************************************
+ @staticmethod
+ def create_webhook(data):
+ return HelpersWebhooks.create_webhook(data)
+
+ @staticmethod
+ def modify_webhook(webhook_id, data):
+ HelpersWebhooks.modify_webhook(webhook_id, data)
+
+ @staticmethod
+ def get_webhook_by_id(webhook_id):
+ return HelpersWebhooks.get_webhook_by_id(webhook_id)
+
+ @staticmethod
+ def get_webhooks_by_server(server_id, model=False):
+ return HelpersWebhooks.get_webhooks_by_server(server_id, model)
+
+ @staticmethod
+ def delete_webhook(webhook_id):
+ HelpersWebhooks.delete_webhook(webhook_id)
+
+ @staticmethod
+ def delete_webhook_by_server(server_id):
+ HelpersWebhooks.delete_webhooks_by_server(server_id)
diff --git a/app/classes/minecraft/stats.py b/app/classes/minecraft/stats.py
index c336612a..a3f85c05 100644
--- a/app/classes/minecraft/stats.py
+++ b/app/classes/minecraft/stats.py
@@ -226,7 +226,7 @@ class Stats:
def get_server_players(self, server_id):
server = HelperServers.get_server_data_by_id(server_id)
- logger.info(f"Getting players for server {server}")
+ logger.debug(f"Getting players for server {server['server_name']}")
internal_ip = server["server_ip"]
server_port = server["server_port"]
diff --git a/app/classes/models/management.py b/app/classes/models/management.py
index fc0a1eb6..e86e3209 100644
--- a/app/classes/models/management.py
+++ b/app/classes/models/management.py
@@ -79,11 +79,15 @@ class HostStats(BaseModel):
# **********************************************************************************
class Webhooks(BaseModel):
id = AutoField()
- name = CharField(max_length=64, unique=True, index=True)
- method = CharField(default="POST")
- url = CharField(unique=True)
- event = CharField(default="")
- send_data = BooleanField(default=True)
+ server_id = IntegerField(null=True)
+ name = CharField(default="Custom Webhook", max_length=64)
+ url = CharField(default="")
+ webhook_type = CharField(default="Custom")
+ bot_name = CharField(default="Crafty Controller")
+ trigger = CharField(default="server_start,server_stop")
+ body = CharField(default="")
+ color = CharField(default="#005cd1")
+ enabled = BooleanField(default=True)
class Meta:
table_name = "webhooks"
@@ -501,3 +505,82 @@ class HelpersManagement:
f"Not removing {dir_to_del} from excluded directories - "
f"not in the excluded directory list for server ID {server_id}"
)
+
+
+# **********************************************************************************
+# Webhooks Class
+# **********************************************************************************
+class HelpersWebhooks:
+ def __init__(self, database):
+ self.database = database
+
+ @staticmethod
+ def create_webhook(create_data) -> int:
+ """Create a webhook in the database
+
+ Args:
+ server_id: ID of a server this webhook will be married to
+ name: The name of the webhook
+ url: URL to the webhook
+ webhook_type: The provider this webhook will be sent to
+ bot name: The name that will appear when the webhook is sent
+ triggers: Server actions that will trigger this webhook
+ body: The message body of the webhook
+ enabled: Should Crafty trigger the webhook
+
+ Returns:
+ int: The new webhooks's id
+
+ Raises:
+ PeeweeException: If the webhook already exists
+ """
+ return Webhooks.insert(
+ {
+ Webhooks.server_id: create_data["server_id"],
+ Webhooks.name: create_data["name"],
+ Webhooks.webhook_type: create_data["webhook_type"],
+ Webhooks.url: create_data["url"],
+ Webhooks.bot_name: create_data["bot_name"],
+ Webhooks.body: create_data["body"],
+ Webhooks.color: create_data["color"],
+ Webhooks.trigger: create_data["trigger"],
+ Webhooks.enabled: create_data["enabled"],
+ }
+ ).execute()
+
+ @staticmethod
+ def modify_webhook(webhook_id, updata):
+ Webhooks.update(updata).where(Webhooks.id == webhook_id).execute()
+
+ @staticmethod
+ def get_webhook_by_id(webhook_id):
+ return model_to_dict(Webhooks.get(Webhooks.id == webhook_id))
+
+ @staticmethod
+ def get_webhooks_by_server(server_id, model):
+ if not model:
+ data = {}
+ for webhook in (
+ Webhooks.select().where(Webhooks.server_id == server_id).execute()
+ ):
+ data[str(webhook.id)] = {
+ "webhook_type": webhook.webhook_type,
+ "name": webhook.name,
+ "url": webhook.url,
+ "bot_name": webhook.bot_name,
+ "trigger": webhook.trigger,
+ "body": webhook.body,
+ "color": webhook.color,
+ "enabled": webhook.enabled,
+ }
+ else:
+ data = Webhooks.select().where(Webhooks.server_id == server_id).execute()
+ return data
+
+ @staticmethod
+ def delete_webhook(webhook_id):
+ Webhooks.delete().where(Webhooks.id == webhook_id).execute()
+
+ @staticmethod
+ def delete_webhooks_by_server(server_id):
+ Webhooks.delete().where(Webhooks.server_id == server_id).execute()
diff --git a/app/classes/shared/server.py b/app/classes/shared/server.py
index 2f62dc68..79dc0f22 100644
--- a/app/classes/shared/server.py
+++ b/app/classes/shared/server.py
@@ -19,13 +19,13 @@ from zoneinfo import ZoneInfo
from tzlocal import get_localzone
from tzlocal.utils import ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler
-from apscheduler.jobstores.base import JobLookupError
+from apscheduler.jobstores.base import JobLookupError, ConflictingIdError
from app.classes.minecraft.stats import Stats
from app.classes.minecraft.mc_ping import ping, ping_bedrock
from app.classes.models.servers import HelperServers, Servers
from app.classes.models.server_stats import HelperServerStats
-from app.classes.models.management import HelpersManagement
+from app.classes.models.management import HelpersManagement, HelpersWebhooks
from app.classes.models.users import HelperUsers
from app.classes.models.server_permissions import PermissionsServers
from app.classes.shared.console import Console
@@ -33,6 +33,7 @@ from app.classes.shared.helpers import Helpers
from app.classes.shared.file_helpers import FileHelpers
from app.classes.shared.null_writer import NullWriter
from app.classes.shared.websocket_manager import WebSocketManager
+from app.classes.web.webhooks.webhook_factory import WebhookFactory
with redirect_stderr(NullWriter()):
import psutil
@@ -165,6 +166,45 @@ class ServerInstance:
self.stats_helper.server_crash_reset()
self.stats_helper.set_update(False)
+ @staticmethod
+ def callback(called_func):
+ # Usage of @callback on method
+ # definition to run a webhook check
+ # on method completion
+ def wrapper(*args, **kwargs):
+ res = None
+ logger.debug("Checking for callbacks")
+ try:
+ res = called_func(*args, **kwargs)
+ finally:
+ events = WebhookFactory.get_monitored_events()
+ if called_func.__name__ in events:
+ server_webhooks = HelpersWebhooks.get_webhooks_by_server(
+ args[0].server_id, True
+ )
+ for swebhook in server_webhooks:
+ if called_func.__name__ in str(swebhook.trigger).split(","):
+ logger.info(
+ f"Found callback for event {called_func.__name__}"
+ f" for server {args[0].server_id}"
+ )
+ webhook = HelpersWebhooks.get_webhook_by_id(swebhook.id)
+ webhook_provider = WebhookFactory.create_provider(
+ webhook["webhook_type"]
+ )
+ if res is not False and swebhook.enabled:
+ webhook_provider.send(
+ bot_name=webhook["bot_name"],
+ server_name=args[0].name,
+ title=webhook["name"],
+ url=webhook["url"],
+ message=webhook["body"],
+ color=webhook["color"],
+ )
+ return res
+
+ return wrapper
+
# **********************************************************************************
# Minecraft Server Management
# **********************************************************************************
@@ -262,13 +302,13 @@ class ServerInstance:
seconds=30,
id="save_stats_" + str(self.server_id),
)
- except:
- self.server_scheduler.remove_job("save_" + str(self.server_id))
+ except ConflictingIdError:
+ self.server_scheduler.remove_job("save_stats_" + str(self.server_id))
self.server_scheduler.add_job(
self.record_server_stats,
"interval",
seconds=30,
- id="save_" + str(self.server_id),
+ id="save_stats_" + str(self.server_id),
)
def setup_server_run_command(self):
@@ -332,6 +372,7 @@ class ServerInstance:
logger.critical(f"Unable to write/access {self.server_path}")
Console.critical(f"Unable to write/access {self.server_path}")
+ @callback
def start_server(self, user_id, forge_install=False):
if not user_id:
user_lang = self.helper.get_setting("language")
@@ -775,6 +816,7 @@ class ServerInstance:
if self.server_thread:
self.server_thread.join()
+ @callback
def stop_server(self):
running = self.check_running()
if not running:
@@ -792,6 +834,7 @@ class ServerInstance:
f"Assuming it was never started."
)
if self.settings["stop_command"]:
+ logger.info(f"Stop command requested for {self.settings['server_name']}.")
self.send_command(self.settings["stop_command"])
self.write_player_cache()
else:
@@ -861,6 +904,9 @@ class ServerInstance:
if not self.check_running():
self.run_threaded_server(user_id)
else:
+ logger.info(
+ f"Restart command detected. Sending stop command to {self.server_id}."
+ )
self.stop_threaded_server()
time.sleep(2)
self.run_threaded_server(user_id)
@@ -882,6 +928,7 @@ class ServerInstance:
self.last_rc = poll
return False
+ @callback
def send_command(self, command):
if not self.check_running() and command.lower() != "start":
logger.warning(f'Server not running, unable to send command "{command}"')
@@ -894,6 +941,7 @@ class ServerInstance:
self.process.stdin.flush()
return True
+ @callback
def crash_detected(self, name):
# clear the old scheduled watcher task
self.server_scheduler.remove_job(f"c_{self.server_id}")
@@ -914,6 +962,7 @@ class ServerInstance:
f"The server {name} has crashed and will be restarted. "
f"Restarting server"
)
+
self.run_threaded_server(None)
return True
logger.critical(
@@ -926,6 +975,7 @@ class ServerInstance:
)
return False
+ @callback
def kill(self):
logger.info(f"Terminating server {self.server_id} and all child processes")
try:
@@ -1014,6 +1064,7 @@ class ServerInstance:
f.write("eula=true")
self.run_threaded_server(user_id)
+ @callback
def backup_server(self):
if self.settings["backup_path"] == "":
logger.critical("Backup path is None. Canceling Backup!")
@@ -1228,6 +1279,7 @@ class ServerInstance:
if f["path"].endswith(".zip")
]
+ @callback
def jar_update(self):
self.stats_helper.set_update(True)
update_thread = threading.Thread(
@@ -1631,7 +1683,7 @@ class ServerInstance:
def get_server_players(self):
server = HelperServers.get_server_data_by_id(self.server_id)
- logger.info(f"Getting players for server {server}")
+ logger.debug(f"Getting players for server {server['server_name']}")
internal_ip = server["server_ip"]
server_port = server["server_port"]
diff --git a/app/classes/web/panel_handler.py b/app/classes/web/panel_handler.py
index 05250a6d..58b6056c 100644
--- a/app/classes/web/panel_handler.py
+++ b/app/classes/web/panel_handler.py
@@ -25,6 +25,7 @@ from app.classes.controllers.roles_controller import RolesController
from app.classes.shared.helpers import Helpers
from app.classes.shared.main_models import DatabaseShortcuts
from app.classes.web.base_handler import BaseHandler
+from app.classes.web.webhooks.webhook_factory import WebhookFactory
logger = logging.getLogger(__name__)
@@ -344,7 +345,9 @@ class PanelHandler(BaseHandler):
) as credits_default_local:
try:
remote = requests.get(
- "https://craftycontrol.com/credits-v2", allow_redirects=True
+ "https://craftycontrol.com/credits-v2",
+ allow_redirects=True,
+ timeout=10,
)
credits_dict: dict = remote.json()
if not credits_dict["staff"]:
@@ -745,6 +748,22 @@ class PanelHandler(BaseHandler):
page_data["history_stats"] = self.controller.servers.get_history_stats(
server_id, hours=(days * 24)
)
+ if subpage == "webhooks":
+ if (
+ not page_data["permissions"]["Config"]
+ in page_data["user_permissions"]
+ ):
+ if not superuser:
+ self.redirect(
+ "/panel/error?error=Unauthorized access to Webhooks Config"
+ )
+ return
+ page_data[
+ "webhooks"
+ ] = self.controller.management.get_webhooks_by_server(
+ server_id, model=True
+ )
+ page_data["triggers"] = WebhookFactory.get_monitored_events()
def get_banned_players_html():
banned_players = self.controller.servers.get_banned_players(server_id)
@@ -1012,6 +1031,110 @@ class PanelHandler(BaseHandler):
template = "panel/panel_edit_user.html"
+ elif page == "add_webhook":
+ server_id = self.get_argument("id", None)
+ if server_id is None:
+ return self.redirect("/panel/error?error=Invalid Server ID")
+ server_obj = self.controller.servers.get_server_instance_by_id(server_id)
+ page_data["backup_failed"] = server_obj.last_backup_status()
+ server_obj = None
+ page_data["active_link"] = "webhooks"
+ page_data["server_data"] = self.controller.servers.get_server_data_by_id(
+ server_id
+ )
+ page_data[
+ "user_permissions"
+ ] = self.controller.server_perms.get_user_id_permissions_list(
+ exec_user["user_id"], server_id
+ )
+ page_data["permissions"] = {
+ "Commands": EnumPermissionsServer.COMMANDS,
+ "Terminal": EnumPermissionsServer.TERMINAL,
+ "Logs": EnumPermissionsServer.LOGS,
+ "Schedule": EnumPermissionsServer.SCHEDULE,
+ "Backup": EnumPermissionsServer.BACKUP,
+ "Files": EnumPermissionsServer.FILES,
+ "Config": EnumPermissionsServer.CONFIG,
+ "Players": EnumPermissionsServer.PLAYERS,
+ }
+ page_data["server_stats"] = self.controller.servers.get_server_stats_by_id(
+ server_id
+ )
+ page_data["server_stats"][
+ "server_type"
+ ] = self.controller.servers.get_server_type_by_id(server_id)
+ page_data["new_webhook"] = True
+ page_data["webhook"] = {}
+ page_data["webhook"]["webhook_type"] = "Custom"
+ page_data["webhook"]["name"] = ""
+ page_data["webhook"]["url"] = ""
+ page_data["webhook"]["bot_name"] = "Crafty Controller"
+ page_data["webhook"]["trigger"] = []
+ page_data["webhook"]["body"] = ""
+ page_data["webhook"]["color"] = "#005cd1"
+ page_data["webhook"]["enabled"] = True
+
+ page_data["providers"] = WebhookFactory.get_supported_providers()
+ page_data["triggers"] = WebhookFactory.get_monitored_events()
+
+ if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]:
+ if not superuser:
+ self.redirect("/panel/error?error=Unauthorized access To Webhooks")
+ return
+
+ template = "panel/server_webhook_edit.html"
+
+ elif page == "webhook_edit":
+ server_id = self.get_argument("id", None)
+ webhook_id = self.get_argument("webhook_id", None)
+ if server_id is None:
+ return self.redirect("/panel/error?error=Invalid Server ID")
+ server_obj = self.controller.servers.get_server_instance_by_id(server_id)
+ page_data["backup_failed"] = server_obj.last_backup_status()
+ server_obj = None
+ page_data["active_link"] = "webhooks"
+ page_data["server_data"] = self.controller.servers.get_server_data_by_id(
+ server_id
+ )
+ page_data[
+ "user_permissions"
+ ] = self.controller.server_perms.get_user_id_permissions_list(
+ exec_user["user_id"], server_id
+ )
+ page_data["permissions"] = {
+ "Commands": EnumPermissionsServer.COMMANDS,
+ "Terminal": EnumPermissionsServer.TERMINAL,
+ "Logs": EnumPermissionsServer.LOGS,
+ "Schedule": EnumPermissionsServer.SCHEDULE,
+ "Backup": EnumPermissionsServer.BACKUP,
+ "Files": EnumPermissionsServer.FILES,
+ "Config": EnumPermissionsServer.CONFIG,
+ "Players": EnumPermissionsServer.PLAYERS,
+ }
+ page_data["server_stats"] = self.controller.servers.get_server_stats_by_id(
+ server_id
+ )
+ page_data["server_stats"][
+ "server_type"
+ ] = self.controller.servers.get_server_type_by_id(server_id)
+ page_data["new_webhook"] = False
+ page_data["webhook"] = self.controller.management.get_webhook_by_id(
+ webhook_id
+ )
+ page_data["webhook"]["trigger"] = str(
+ page_data["webhook"]["trigger"]
+ ).split(",")
+
+ page_data["providers"] = WebhookFactory.get_supported_providers()
+ page_data["triggers"] = WebhookFactory.get_monitored_events()
+
+ if not EnumPermissionsServer.CONFIG in page_data["user_permissions"]:
+ if not superuser:
+ self.redirect("/panel/error?error=Unauthorized access To Webhooks")
+ return
+
+ template = "panel/server_webhook_edit.html"
+
elif page == "add_schedule":
server_id = self.get_argument("id", None)
if server_id is None:
diff --git a/app/classes/web/routes/api/api_handlers.py b/app/classes/web/routes/api/api_handlers.py
index 49dcb9de..706c346f 100644
--- a/app/classes/web/routes/api/api_handlers.py
+++ b/app/classes/web/routes/api/api_handlers.py
@@ -50,6 +50,12 @@ from app.classes.web.routes.api.servers.server.tasks.task.children import (
from app.classes.web.routes.api.servers.server.tasks.task.index import (
ApiServersServerTasksTaskIndexHandler,
)
+from app.classes.web.routes.api.servers.server.webhooks.index import (
+ ApiServersServerWebhooksIndexHandler,
+)
+from app.classes.web.routes.api.servers.server.webhooks.webhook.index import (
+ ApiServersServerWebhooksManagementIndexHandler,
+)
from app.classes.web.routes.api.servers.server.users import ApiServersServerUsersHandler
from app.classes.web.routes.api.users.index import ApiUsersIndexHandler
from app.classes.web.routes.api.users.user.index import ApiUsersUserIndexHandler
@@ -250,6 +256,16 @@ def api_handlers(handler_args):
ApiServersServerHistoryHandler,
handler_args,
),
+ (
+ r"/api/v2/servers/([0-9]+)/webhook/([0-9]+)/?",
+ ApiServersServerWebhooksManagementIndexHandler,
+ handler_args,
+ ),
+ (
+ r"/api/v2/servers/([0-9]+)/webhook/?",
+ ApiServersServerWebhooksIndexHandler,
+ handler_args,
+ ),
(
r"/api/v2/servers/([0-9]+)/action/([a-z_]+)/?",
ApiServersServerActionHandler,
diff --git a/app/classes/web/routes/api/servers/server/webhooks/index.py b/app/classes/web/routes/api/servers/server/webhooks/index.py
new file mode 100644
index 00000000..223171c8
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/webhooks/index.py
@@ -0,0 +1,108 @@
+# TODO: create and read
+
+import json
+import logging
+
+from jsonschema import ValidationError, validate
+from app.classes.models.server_permissions import EnumPermissionsServer
+from app.classes.web.base_api_handler import BaseApiHandler
+from app.classes.web.webhooks.webhook_factory import WebhookFactory
+
+
+logger = logging.getLogger(__name__)
+new_webhook_schema = {
+ "type": "object",
+ "properties": {
+ "webhook_type": {
+ "type": "string",
+ "enum": WebhookFactory.get_supported_providers(),
+ },
+ "name": {"type": "string"},
+ "url": {"type": "string"},
+ "bot_name": {"type": "string"},
+ "trigger": {"type": "array"},
+ "body": {"type": "string"},
+ "color": {"type": "string", "default": "#005cd1"},
+ "enabled": {
+ "type": "boolean",
+ "default": True,
+ },
+ },
+ "additionalProperties": False,
+ "minProperties": 7,
+}
+
+
+class ApiServersServerWebhooksIndexHandler(BaseApiHandler):
+ def get(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.management.get_webhooks_by_server(server_id),
+ },
+ )
+
+ def post(self, server_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ try:
+ data = json.loads(self.request.body)
+ except json.decoder.JSONDecodeError as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
+ )
+
+ try:
+ validate(data, new_webhook_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ if server_id not in [str(x["server_id"]) for x in auth_data[0]]:
+ # if the user doesn't have access to the server, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ data["server_id"] = server_id
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Edited server {server_id}: added webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+ triggers = ""
+ for item in data["trigger"]:
+ string = item + ","
+ triggers += string
+ data["trigger"] = triggers
+ webhook_id = self.controller.management.create_webhook(data)
+
+ self.finish_json(200, {"status": "ok", "data": {"webhook_id": webhook_id}})
diff --git a/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py b/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py
new file mode 100644
index 00000000..4b58011e
--- /dev/null
+++ b/app/classes/web/routes/api/servers/server/webhooks/webhook/index.py
@@ -0,0 +1,187 @@
+# TODO: read and delete
+
+import json
+import logging
+
+from jsonschema import ValidationError, validate
+from app.classes.models.server_permissions import EnumPermissionsServer
+from app.classes.web.webhooks.webhook_factory import WebhookFactory
+from app.classes.web.base_api_handler import BaseApiHandler
+
+
+logger = logging.getLogger(__name__)
+
+webhook_patch_schema = {
+ "type": "object",
+ "properties": {
+ "webhook_type": {
+ "type": "string",
+ "enum": WebhookFactory.get_supported_providers(),
+ },
+ "name": {"type": "string"},
+ "url": {"type": "string"},
+ "bot_name": {"type": "string"},
+ "trigger": {"type": "array"},
+ "body": {"type": "string"},
+ "color": {"type": "string", "default": "#005cd1"},
+ "enabled": {
+ "type": "boolean",
+ "default": True,
+ },
+ },
+ "additionalProperties": False,
+ "minProperties": 1,
+}
+
+
+class ApiServersServerWebhooksManagementIndexHandler(BaseApiHandler):
+ def get(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ if (
+ not str(webhook_id)
+ in self.controller.management.get_webhooks_by_server(server_id).keys()
+ ):
+ return self.finish_json(
+ 400, {"status": "error", "error": "NO WEBHOOK FOUND"}
+ )
+ self.finish_json(
+ 200,
+ {
+ "status": "ok",
+ "data": self.controller.management.get_webhook_by_id(webhook_id),
+ },
+ )
+
+ def delete(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ try:
+ self.controller.management.delete_webhook(webhook_id)
+ except Exception:
+ return self.finish_json(
+ 400, {"status": "error", "error": "NO WEBHOOK FOUND"}
+ )
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Edited server {server_id}: removed webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+
+ return self.finish_json(200, {"status": "ok"})
+
+ def patch(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ try:
+ data = json.loads(self.request.body)
+ except json.decoder.JSONDecodeError as e:
+ return self.finish_json(
+ 400, {"status": "error", "error": "INVALID_JSON", "error_data": str(e)}
+ )
+
+ try:
+ validate(data, webhook_patch_schema)
+ except ValidationError as e:
+ return self.finish_json(
+ 400,
+ {
+ "status": "error",
+ "error": "INVALID_JSON_SCHEMA",
+ "error_data": str(e),
+ },
+ )
+
+ if server_id not in [str(x["server_id"]) for x in auth_data[0]]:
+ # if the user doesn't have access to the server, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ data["server_id"] = server_id
+ if "trigger" in data.keys():
+ triggers = ""
+ for item in data["trigger"]:
+ string = item + ","
+ triggers += string
+ data["trigger"] = triggers
+ self.controller.management.modify_webhook(webhook_id, data)
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ f"Edited server {server_id}: updated webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+
+ self.finish_json(200, {"status": "ok"})
+
+ def post(self, server_id: str, webhook_id: str):
+ auth_data = self.authenticate_user()
+ if not auth_data:
+ return
+
+ self.controller.management.add_to_audit_log(
+ auth_data[4]["user_id"],
+ "Tested webhook",
+ server_id,
+ self.get_remote_ip(),
+ )
+ if server_id not in [str(x["server_id"]) for x in auth_data[0]]:
+ # if the user doesn't have access to the server, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+
+ if (
+ EnumPermissionsServer.CONFIG
+ not in self.controller.server_perms.get_user_id_permissions_list(
+ auth_data[4]["user_id"], server_id
+ )
+ ):
+ # if the user doesn't have Schedule permission, return an error
+ return self.finish_json(400, {"status": "error", "error": "NOT_AUTHORIZED"})
+ webhook = self.controller.management.get_webhook_by_id(webhook_id)
+ try:
+ webhook_provider = WebhookFactory.create_provider(webhook["webhook_type"])
+ webhook_provider.send(
+ server_name=self.controller.servers.get_server_data_by_id(server_id)[
+ "server_name"
+ ],
+ title=f"Test Webhook: {webhook['name']}",
+ url=webhook["url"],
+ message=webhook["body"],
+ color=webhook["color"], # Prestigious purple!
+ bot_name="Crafty Webhooks Tester",
+ )
+ except Exception as e:
+ self.finish_json(500, {"status": "error", "error": str(e)})
+
+ self.finish_json(200, {"status": "ok"})
diff --git a/app/classes/web/webhooks/base_webhook.py b/app/classes/web/webhooks/base_webhook.py
new file mode 100644
index 00000000..75e485fc
--- /dev/null
+++ b/app/classes/web/webhooks/base_webhook.py
@@ -0,0 +1,39 @@
+from abc import ABC, abstractmethod
+import logging
+import requests
+
+from app.classes.shared.helpers import Helpers
+
+logger = logging.getLogger(__name__)
+helper = Helpers()
+
+
+class WebhookProvider(ABC):
+ """
+ Base class for all webhook providers.
+
+ Provides a common interface for all webhook provider implementations,
+ ensuring that each provider will have a send method.
+ """
+
+ WEBHOOK_USERNAME = "Crafty Webhooks"
+ WEBHOOK_PFP_URL = (
+ "https://gitlab.com/crafty-controller/crafty-4/-"
+ + "/raw/master/app/frontend/static/assets/images/"
+ + "Crafty_4-0.png"
+ )
+ CRAFTY_VERSION = helper.get_version_string()
+
+ def _send_request(self, url, payload, headers=None):
+ """Send a POST request to the given URL with the provided payload."""
+ try:
+ response = requests.post(url, json=payload, headers=headers, timeout=10)
+ response.raise_for_status()
+ return "Dispatch successful"
+ except requests.RequestException as error:
+ logger.error(error)
+ raise RuntimeError(f"Failed to dispatch notification: {error}") from error
+
+ @abstractmethod
+ def send(self, server_name, title, url, message, **kwargs):
+ """Abstract method that derived classes will implement for sending webhooks."""
diff --git a/app/classes/web/webhooks/discord_webhook.py b/app/classes/web/webhooks/discord_webhook.py
new file mode 100644
index 00000000..eebe38aa
--- /dev/null
+++ b/app/classes/web/webhooks/discord_webhook.py
@@ -0,0 +1,82 @@
+from datetime import datetime
+from app.classes.web.webhooks.base_webhook import WebhookProvider
+
+
+class DiscordWebhook(WebhookProvider):
+ def _construct_discord_payload(self, server_name, title, message, color, bot_name):
+ """
+ Constructs the payload required for sending a Discord webhook notification.
+
+ This method prepares a payload for the Discord webhook API using the provided
+ message content, the Crafty Controller version, and the current UTC datetime.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ message (str): The main content of the notification message.
+ color (int): The color code for the side stripe in the Discord embed message.
+ bot_name (str): Override for the Webhook's name set on creation
+
+ Returns:
+ tuple: A tuple containing the constructed payload (dict) incl headers (dict).
+
+ Note:
+ - Discord embed designer
+ - https://discohook.org/
+ """
+ current_datetime = datetime.utcnow()
+ formatted_datetime = (
+ current_datetime.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
+ )
+
+ # Convert the hex to an integer
+ sanitized_hex = color[1:] if color.startswith("#") else color
+ color_int = int(sanitized_hex, 16)
+
+ headers = {"Content-type": "application/json"}
+ payload = {
+ "username": bot_name,
+ "avatar_url": self.WEBHOOK_PFP_URL,
+ "embeds": [
+ {
+ "title": title,
+ "description": message,
+ "color": color_int,
+ "author": {"name": server_name},
+ "footer": {"text": f"Crafty Controller v.{self.CRAFTY_VERSION}"},
+ "timestamp": formatted_datetime,
+ }
+ ],
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Discord webhook notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Discords's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ color (str, optional): The color code for the embed's side stripe.
+ Defaults to a pretty blue if not provided.
+ bot_name (str): Override for the Webhook's name set on creation
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+ """
+ color = kwargs.get("color", "#005cd1") # Default to a color if not provided.
+ bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
+ payload, headers = self._construct_discord_payload(
+ server_name, title, message, color, bot_name
+ )
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/mattermost_webhook.py b/app/classes/web/webhooks/mattermost_webhook.py
new file mode 100644
index 00000000..3dc97c05
--- /dev/null
+++ b/app/classes/web/webhooks/mattermost_webhook.py
@@ -0,0 +1,74 @@
+from app.classes.web.webhooks.base_webhook import WebhookProvider
+
+
+class MattermostWebhook(WebhookProvider):
+ def _construct_mattermost_payload(self, server_name, title, message, bot_name):
+ """
+ Constructs the payload required for sending a Mattermost webhook notification.
+
+ The method formats the given information into a Markdown-styled message for MM,
+ including an information card containing the Crafty version.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ message (str): The main content of the notification message.
+ bot_name (str): Override for the Webhook's name set on creation.
+
+ Returns:
+ tuple: A tuple containing the constructed payload (dict) incl headers (dict).
+ """
+ formatted_text = (
+ f"-----\n\n"
+ f"#### {title}\n"
+ f"##### Server: ```{server_name}```\n\n"
+ f"```\n{message}\n```\n\n"
+ f"-----"
+ )
+
+ headers = {"Content-Type": "application/json"}
+ payload = {
+ "text": formatted_text,
+ "username": bot_name,
+ "icon_url": self.WEBHOOK_PFP_URL,
+ "props": {
+ "card": (
+ f"[Crafty Controller "
+ f"v.{self.CRAFTY_VERSION}](https://craftycontrol.com)"
+ )
+ },
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Mattermost webhook notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Mattermost's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ bot_name (str): Override for the Webhook's name set on creation, see note!
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+
+ Note:
+ - To set webhook username & pfp Mattermost needs to be configured to allow this!
+ - Mattermost's `config.json` setting is `"EnablePostUsernameOverride": true`
+ - Mattermost's `config.json` setting is `"EnablePostIconOverride": true`
+ """
+ bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
+ payload, headers = self._construct_mattermost_payload(
+ server_name, title, message, bot_name
+ )
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/slack_webhook.py b/app/classes/web/webhooks/slack_webhook.py
new file mode 100644
index 00000000..cd7c71bf
--- /dev/null
+++ b/app/classes/web/webhooks/slack_webhook.py
@@ -0,0 +1,98 @@
+from app.classes.web.webhooks.base_webhook import WebhookProvider
+
+
+class SlackWebhook(WebhookProvider):
+ def _construct_slack_payload(self, server_name, title, message, color, bot_name):
+ """
+ Constructs the payload required for sending a Slack webhook notification.
+
+ The method formats the given information into a Markdown-styled message for MM,
+ including an information card containing the Crafty version.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ message (str): The main content of the notification message.
+ color (int): The color code for the side stripe in the Slack block.
+ bot_name (str): Override for the Webhook's name set on creation, (not working).
+
+ Returns:
+ tuple: A tuple containing the constructed payload (dict) incl headers (dict).
+
+ Note:
+ - Block Builder/designer
+ - https://app.slack.com/block-kit-builder/
+ """
+ headers = {"Content-Type": "application/json"}
+ payload = {
+ "username": bot_name,
+ "attachments": [
+ {
+ "color": color,
+ "blocks": [
+ {
+ "type": "section",
+ "text": {"type": "plain_text", "text": server_name},
+ },
+ {"type": "divider"},
+ {
+ "type": "section",
+ "text": {
+ "type": "mrkdwn",
+ "text": f"*{title}*\n{message}",
+ },
+ "accessory": {
+ "type": "image",
+ "image_url": self.WEBHOOK_PFP_URL,
+ "alt_text": "Crafty Controller Logo",
+ },
+ },
+ {
+ "type": "context",
+ "elements": [
+ {
+ "type": "mrkdwn",
+ "text": (
+ f"*Crafty Controller "
+ f"v{self.CRAFTY_VERSION}*"
+ ),
+ }
+ ],
+ },
+ {"type": "divider"},
+ ],
+ }
+ ],
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Slack webhook notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Slack's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ color (str, optional): The color code for the blocks's colour accent.
+ Defaults to a pretty blue if not provided.
+ bot_name (str): Override for the Webhook's name set on creation, (not working).
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+ """
+ color = kwargs.get("color", "#005cd1") # Default to a color if not provided.
+ bot_name = kwargs.get("bot_name", self.WEBHOOK_USERNAME)
+ payload, headers = self._construct_slack_payload(
+ server_name, title, message, color, bot_name
+ )
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/teams_adaptive_webhook.py b/app/classes/web/webhooks/teams_adaptive_webhook.py
new file mode 100644
index 00000000..1e4f442d
--- /dev/null
+++ b/app/classes/web/webhooks/teams_adaptive_webhook.py
@@ -0,0 +1,124 @@
+from datetime import datetime
+from app.classes.web.webhooks.base_webhook import WebhookProvider
+
+
+class TeamsWebhook(WebhookProvider):
+ def _construct_teams_payload(self, server_name, title, message):
+ """
+ Constructs the payload required for sending a Teams Adaptive card notification.
+
+ This method prepares a payload for the Teams webhook API using the provided
+ message content, the Crafty Controller version, and the current UTC datetime.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ message (str): The main content of the notification message.
+
+ Returns:
+ tuple: A tuple containing the constructed payload (dict) incl headers (dict).
+
+ Note:
+ - Adaptive Card Designer
+ - https://www.adaptivecards.io/designer/
+ """
+ current_datetime = datetime.utcnow()
+ formatted_datetime = current_datetime.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ headers = {"Content-type": "application/json"}
+ payload = {
+ "type": "message",
+ "attachments": [
+ {
+ "contentType": "application/vnd.microsoft.card.adaptive",
+ "content": {
+ "body": [
+ {
+ "type": "TextBlock",
+ "size": "Medium",
+ "weight": "Bolder",
+ "text": f"{title}",
+ },
+ {
+ "type": "ColumnSet",
+ "columns": [
+ {
+ "type": "Column",
+ "items": [
+ {
+ "type": "Image",
+ "style": "Person",
+ "url": f"{self.WEBHOOK_PFP_URL}",
+ "size": "Small",
+ }
+ ],
+ "width": "auto",
+ },
+ {
+ "type": "Column",
+ "items": [
+ {
+ "type": "TextBlock",
+ "weight": "Bolder",
+ "text": f"{server_name}",
+ "wrap": True,
+ },
+ {
+ "type": "TextBlock",
+ "spacing": "None",
+ "text": "{{DATE("
+ + f"{formatted_datetime}"
+ + ",SHORT)}}",
+ "isSubtle": True,
+ "wrap": True,
+ },
+ ],
+ "width": "stretch",
+ },
+ ],
+ },
+ {
+ "type": "TextBlock",
+ "text": f"{message}",
+ "wrap": True,
+ },
+ {
+ "type": "TextBlock",
+ "text": f"Crafty Controller v{self.CRAFTY_VERSION}",
+ "wrap": True,
+ "separator": True,
+ "isSubtle": True,
+ },
+ ],
+ "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
+ "version": "1.6",
+ },
+ }
+ ],
+ }
+
+ return payload, headers
+
+ def send(self, server_name, title, url, message, **kwargs):
+ """
+ Sends a Teams Adaptive card notification using the given details.
+
+ The method constructs and dispatches a payload suitable for
+ Discords's webhook system.
+
+ Parameters:
+ server_name (str): The name of the server triggering the notification.
+ title (str): The title for the notification message.
+ url (str): The webhook URL to send the notification to.
+ message (str): The main content or body of the notification message.
+ Defaults to a pretty blue if not provided.
+
+ Returns:
+ str: "Dispatch successful!" if the message is sent successfully, otherwise an
+ exception is raised.
+
+ Raises:
+ Exception: If there's an error in dispatching the webhook.
+ """
+ payload, headers = self._construct_teams_payload(server_name, title, message)
+ return self._send_request(url, payload, headers)
diff --git a/app/classes/web/webhooks/webhook_factory.py b/app/classes/web/webhooks/webhook_factory.py
new file mode 100644
index 00000000..608bf4e5
--- /dev/null
+++ b/app/classes/web/webhooks/webhook_factory.py
@@ -0,0 +1,85 @@
+from app.classes.web.webhooks.discord_webhook import DiscordWebhook
+from app.classes.web.webhooks.mattermost_webhook import MattermostWebhook
+from app.classes.web.webhooks.slack_webhook import SlackWebhook
+from app.classes.web.webhooks.teams_adaptive_webhook import TeamsWebhook
+
+
+class WebhookFactory:
+ """
+ A factory class responsible for the creation and management of webhook providers.
+
+ This class provides methods to instantiate specific webhook providers based on
+ their name and to retrieve a list of supported providers. It uses a registry pattern
+ to manage the available providers.
+
+ Attributes:
+ - _registry (dict): A dictionary mapping provider names to their classes.
+ """
+
+ _registry = {
+ "Discord": DiscordWebhook,
+ "Mattermost": MattermostWebhook,
+ "Slack": SlackWebhook,
+ "Teams": TeamsWebhook,
+ # "Custom",
+ }
+
+ @classmethod
+ def create_provider(cls, provider_name, *args, **kwargs):
+ """
+ Creates and returns an instance of the specified webhook provider.
+
+ This method looks up the provider in the registry, then instantiates it w/ the
+ provided arguments. If the provider is not recognized, a ValueError is raised.
+
+ Arguments:
+ - provider_name (str): The name of the desired webhook provider.
+
+ Additional arguments supported that we may use for if a provider
+ requires initialization:
+ - *args: Positional arguments to pass to the provider's constructor.
+ - **kwargs: Keyword arguments to pass to the provider's constructor.
+
+ Returns:
+ WebhookProvider: An instance of the desired webhook provider.
+
+ Raises:
+ ValueError: If the specified provider name is not recognized.
+ """
+ if provider_name not in cls._registry:
+ raise ValueError(f"Provider {provider_name} is not supported.")
+ return cls._registry[provider_name](*args, **kwargs)
+
+ @classmethod
+ def get_supported_providers(cls):
+ """
+ Retrieves the names of all supported webhook providers.
+
+ This method returns a list containing the names of all providers
+ currently registered in the factory's registry.
+
+ Returns:
+ List[str]: A list of supported provider names.
+ """
+ return list(cls._registry.keys())
+
+ @staticmethod
+ def get_monitored_events():
+ """
+ Retrieves the list of supported events for monitoring.
+
+ This method provides a list of common server events that the webhook system can
+ monitor and notify about.
+
+ Returns:
+ List[str]: A list of supported monitored actions.
+ """
+ return [
+ "start_server",
+ "stop_server",
+ "crash_detected",
+ "backup_server",
+ "jar_update",
+ "send_command",
+ "kill",
+ ]
diff --git a/app/frontend/templates/panel/parts/m_server_controls_list.html b/app/frontend/templates/panel/parts/m_server_controls_list.html
index 63c6b98a..f311ef10 100644
--- a/app/frontend/templates/panel/parts/m_server_controls_list.html
+++ b/app/frontend/templates/panel/parts/m_server_controls_list.html
@@ -31,6 +31,9 @@
{{ translate('serverDetails', 'playerControls', data['lang']) }}
{% end %}
{{ translate('serverDetails', 'metrics', data['lang']) }}
+ {% if data['permissions']['Config'] in data['user_permissions'] %}
+ {{ translate('webhooks', 'webhooks', data['lang']) }}
+ {% end %}
\ No newline at end of file
diff --git a/app/frontend/templates/panel/parts/server_controls_list.html b/app/frontend/templates/panel/parts/server_controls_list.html
index 4f5bb6b5..df63f13d 100644
--- a/app/frontend/templates/panel/parts/server_controls_list.html
+++ b/app/frontend/templates/panel/parts/server_controls_list.html
@@ -53,4 +53,10 @@
{{ translate('serverDetails', 'metrics', data['lang']) }}
+ {% if data['permissions']['Config'] in data['user_permissions'] %}
+
{{ translate('webhooks', 'name', data['lang']) }} + | +{{ translate('webhooks', 'type', data['lang']) }} | +{{ translate('webhooks', 'trigger', data['lang']) }} | +{{ translate('webhooks', 'enabled', + data['lang']) }} | +{{ translate('webhooks', 'edit', data['lang']) + }} | +
---|---|---|---|---|
+ {{webhook.name}} + |
+
+ {{webhook.webhook_type}} + |
+
+
|
+ + + | +
+
+ + + + + |
+
Name + | +{{ translate('webhooks', 'enabled', + data['lang']) }} | +{{ translate('webhooks', 'edit', data['lang']) + }} | +
---|---|---|
+ {{webhook.name}} + |
+ + + | +
+
+ + + + + |
+