revamped the notification system
This commit is contained in:
parent
922d194d7a
commit
ddc216ccb6
13 changed files with 1024 additions and 187 deletions
42
README.md
42
README.md
|
|
@ -10,13 +10,13 @@ YTPTube started as a fork of [meTube](https://github.com/alexta69/metube), Since
|
|||
* Multi-downloads support.
|
||||
* Handle live streams.
|
||||
* Schedule Channels or Playlists to be downloaded automatically at a specific time.
|
||||
* Send notification to targets based on specified events.
|
||||
* Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`.
|
||||
* Queue multiple URLs separated by comma.
|
||||
* A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**.
|
||||
* New `/api/add_batch` endpoint that allow multiple links to be sent.
|
||||
* Completely redesigned the frontend UI.
|
||||
* Switched out of binary file storage in favor of SQLite.
|
||||
* Webhook sender. It allow you to add webhook endpoints that receive events related to downloads using simple `json` file.
|
||||
* Basic Authentication support.
|
||||
* Support for curl_cffi, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation)
|
||||
* Support for both advanced and basic mode for WebUI.
|
||||
|
|
@ -235,34 +235,6 @@ The `config/ytdlp.json`, is a json file which can be used to alter the default `
|
|||
}
|
||||
```
|
||||
|
||||
### webhooks.json File
|
||||
|
||||
The `config/webhooks.json`, is a json file, which can be used to add webhook endpoints that would receive events related to the downloads.
|
||||
|
||||
```json5
|
||||
[
|
||||
{
|
||||
// (name: string) - REQUIRED - The webhook name.
|
||||
"name": "my very smart webhook receiver",
|
||||
// (on: array) - OPTIONAL - List of accepted events, if left empty it will send all events.
|
||||
// Allowed events ["added", "completed", "error", "not_live" ] you can choose one or all of them.
|
||||
"on": [ "added", "completed", "error", "not_live" ],
|
||||
"request": {
|
||||
// (url: string) - REQUIRED- The webhook url
|
||||
"url": "https://mysecert.webhook.com/endpoint",
|
||||
// (type: string) - OPTIONAL - The request type, it can be json or form.
|
||||
"type": "json",
|
||||
// (method: string) - OPTIONAL - The request method, it can be POST or PUT
|
||||
"method": "POST",
|
||||
// (headers: dictionary) - OPTIONAL - Extra headers to include.
|
||||
"headers": {
|
||||
"Authorization": "Bearer my_secret_token"
|
||||
}
|
||||
}
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
### presets.json File
|
||||
|
||||
The `config/presets.json`, is a json file, which can be used to add custom presets for selection in WebUI.
|
||||
|
|
@ -312,20 +284,20 @@ What does the basic mode do? it hides the the following features from the WebUI.
|
|||
|
||||
### Header
|
||||
|
||||
It disables the `Check cookies`, `Console`, `Tasks` and `Add` buttons.
|
||||
It disables everything except the `theme switcher` and `reload` button.
|
||||
|
||||
### Add form
|
||||
|
||||
Disables everything except the `URL` and `Add` button. the default preset `YTP_DEFAULT_PRESET` will be used. The folder will be
|
||||
the root download path `YTP_DOWNLOAD_PATH`.
|
||||
|
||||
The add form will always be visible and un-collapsible.
|
||||
* The form will always be visible and un-collapsible.
|
||||
* Everything except the `URL` and `Add` button will be disabled and hidden.
|
||||
* The preset will be the default preset, which can be specified via `YTP_DEFAULT_PRESET` environment variable.
|
||||
* The output template will be the default template which can be specified via `YTP_OUTPUT_TEMPLATE` environment variable.
|
||||
* The download path will be the default download path which can be specified via `YTP_DOWNLOAD_PATH` environment variable.
|
||||
|
||||
### Queue & History
|
||||
|
||||
Disables the `Information` button.
|
||||
|
||||
|
||||
# Social contact
|
||||
|
||||
If you have short or quick questions, you are free to join my [discord server](https://discord.gg/G3GpVR8xpb) and ask
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ from .M3u8 import M3u8
|
|||
from .Playlist import Playlist
|
||||
from .Segments import Segments
|
||||
from .Subtitle import Subtitle
|
||||
from .Utils import StreamingError, arg_converter, calcDownloadPath, getVideoInfo, validate_url
|
||||
from .Utils import StreamingError, arg_converter, calcDownloadPath, getVideoInfo, validate_url, validateUUID
|
||||
from .Notifications import Notification
|
||||
|
||||
LOG = logging.getLogger("http_api")
|
||||
MIME = magic.Magic(mime=True)
|
||||
|
|
@ -830,3 +831,69 @@ class HttpAPI(common):
|
|||
data={"message": f"Failed to request website. {str(e)}"},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
@route("GET", "api/notifications")
|
||||
async def notifications(self, _: Request) -> Response:
|
||||
data = {
|
||||
"notifications": Notification.get_instance().getTargets(),
|
||||
"allowedTypes": Notification.allowed_events,
|
||||
}
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||
|
||||
@route("POST", "api/notifications/test")
|
||||
async def test_notifications(self, _: Request) -> Response:
|
||||
data = {"type": "test", "message": "This is a test notification."}
|
||||
await self.emitter.emit("test", data)
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||
|
||||
@route("PUT", "api/notifications")
|
||||
async def add_notifications(self, request: Request) -> Response:
|
||||
post = await request.json()
|
||||
if not isinstance(post, list):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
targets: list = []
|
||||
|
||||
for item in post:
|
||||
if not isinstance(item, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting list with dicts."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not item.get("id", None) or validateUUID(item.get("id"), version=4):
|
||||
item["id"] = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
Notification.validate(item)
|
||||
except ValueError as e:
|
||||
return web.json_response(
|
||||
{"error": f"Invalid notification target settings. {str(e)}", "data": item},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
targets.append(item)
|
||||
|
||||
ins = Notification.get_instance()
|
||||
|
||||
try:
|
||||
if len(targets) < 1:
|
||||
ins.clearTargets()
|
||||
|
||||
ins.save(targets=targets)
|
||||
ins.load()
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response({"error": "Failed to save tasks."}, status=web.HTTPInternalServerError.status_code)
|
||||
|
||||
data = {
|
||||
"notifications": targets,
|
||||
"allowedTypes": Notification.allowed_events,
|
||||
}
|
||||
|
||||
return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode)
|
||||
|
|
|
|||
310
app/library/Notifications.py
Normal file
310
app/library/Notifications.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, TypedDict
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import Config
|
||||
from .ItemDTO import ItemDTO
|
||||
from .Singleton import Singleton
|
||||
from .Utils import ag, validateUUID
|
||||
from .version import APP_VERSION
|
||||
from .encoder import Encoder
|
||||
|
||||
LOG = logging.getLogger("notifications")
|
||||
|
||||
|
||||
class targetRequestHeader(TypedDict):
|
||||
"""Request header details for a notification target."""
|
||||
|
||||
key: str
|
||||
value: str
|
||||
|
||||
|
||||
class targetRequest(TypedDict):
|
||||
"""Request details for a notification target."""
|
||||
|
||||
type: str
|
||||
method: str
|
||||
url: str
|
||||
headers: list[targetRequestHeader] = []
|
||||
|
||||
|
||||
class Target(TypedDict):
|
||||
"""Notification target details."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
on: List[str]
|
||||
request: targetRequest
|
||||
|
||||
|
||||
class Notification(metaclass=Singleton):
|
||||
targets: list[Target] = []
|
||||
"""Notification targets to send events to."""
|
||||
|
||||
allowed_events: tuple = (
|
||||
"added",
|
||||
"completed",
|
||||
"error",
|
||||
"not_live",
|
||||
"canceled",
|
||||
"cleared",
|
||||
"log_info",
|
||||
"log_success",
|
||||
)
|
||||
"""Events that can be sent to the targets."""
|
||||
|
||||
_instance = None
|
||||
|
||||
def __init__(self):
|
||||
Notification._instance = self
|
||||
|
||||
config = Config.get_instance()
|
||||
|
||||
self._debug = config.debug
|
||||
self.file: str = os.path.join(config.config_path, "notifications.json")
|
||||
self._client: httpx.AsyncClient = httpx.AsyncClient()
|
||||
self._encoder: Encoder = Encoder()
|
||||
|
||||
if os.path.exists(self.file) and os.path.getsize(self.file) > 10:
|
||||
self.load()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> "Notification":
|
||||
if Notification._instance is None:
|
||||
Notification._instance = Notification()
|
||||
|
||||
return Notification._instance
|
||||
|
||||
def getTargets(self) -> list[Target]:
|
||||
"""Get the list of notification targets."""
|
||||
return self.targets
|
||||
|
||||
def clearTargets(self) -> "Notification":
|
||||
"""Clear the list of notification targets."""
|
||||
self.targets.clear()
|
||||
return self
|
||||
|
||||
def save(self, targets: list[Target] | None = None) -> "Notification":
|
||||
"""
|
||||
Save notification targets to the file.
|
||||
|
||||
Args:
|
||||
targets (list[Target]|None): The list of targets to save.
|
||||
|
||||
Returns:
|
||||
Notification: The Notification instance.
|
||||
"""
|
||||
LOG.info(f"Saving notification targets to '{self.file}'.")
|
||||
try:
|
||||
with open(self.file, "w") as f:
|
||||
f.write(json.dumps(targets or self.targets, indent=4))
|
||||
except Exception as e:
|
||||
LOG.error(f"Error saving notification targets to '{self.file}'. '{e}'")
|
||||
pass
|
||||
|
||||
return self
|
||||
|
||||
def load(self):
|
||||
"""Load or reload notification targets from the file."""
|
||||
if len(self.targets) > 0:
|
||||
LOG.info("Clearing existing notification targets.")
|
||||
self.targets.clear()
|
||||
|
||||
modified = False
|
||||
|
||||
targets = []
|
||||
|
||||
if not os.path.exists(self.file) or os.path.getsize(self.file) < 10:
|
||||
return
|
||||
|
||||
LOG.info(f"Loading notification targets from '{self.file}'.")
|
||||
|
||||
try:
|
||||
with open(self.file, "r") as f:
|
||||
targets = json.load(f)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error loading notification targets from '{self.file}'. '{e}'")
|
||||
pass
|
||||
|
||||
for target in targets:
|
||||
try:
|
||||
try:
|
||||
Notification.validate(target)
|
||||
except ValueError as e:
|
||||
LOG.error(f"Invalid notification target '{target}'. '{e}'")
|
||||
continue
|
||||
|
||||
if "on" not in target:
|
||||
target["on"] = []
|
||||
modified = True
|
||||
|
||||
if "type" not in target["request"]:
|
||||
target["request"]["type"] = "json"
|
||||
modified = True
|
||||
|
||||
if "method" not in target["request"]:
|
||||
target["request"]["method"] = "POST"
|
||||
modified = True
|
||||
|
||||
if "id" not in target or validateUUID(target["id"], version=4) is False:
|
||||
target["id"] = str(uuid.uuid4())
|
||||
modified = True
|
||||
|
||||
target = Target(
|
||||
id=target.get("id"),
|
||||
name=target.get("name"),
|
||||
on=target.get("on", []),
|
||||
request=targetRequest(
|
||||
type=target.get("request", {}).get("type", "json"),
|
||||
method=target.get("request", {}).get("method", "POST"),
|
||||
url=target.get("request", {}).get("url"),
|
||||
headers=[
|
||||
targetRequestHeader(key=h.get("key"), value=h.get("value"))
|
||||
for h in target.get("request", {}).get("headers", [])
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
self.targets.append(target)
|
||||
|
||||
LOG.info(
|
||||
f"Will send '{target['on'] if len(target['on']) > 0 else 'all'}' as {target['request']['type']} notification events to '{target['name']}'."
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error loading notification target '{target}'. '{e}'")
|
||||
pass
|
||||
|
||||
if modified:
|
||||
self.save()
|
||||
|
||||
@staticmethod
|
||||
def validate(target: Target | dict) -> bool:
|
||||
"""
|
||||
Validate a notification target.
|
||||
|
||||
Args:
|
||||
target (Target|dict): The target to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the target is valid, False otherwise.
|
||||
"""
|
||||
|
||||
if "id" not in target or validateUUID(target["id"], version=4) is False:
|
||||
raise ValueError("Invalid notification target. No ID found.")
|
||||
|
||||
if "name" not in target:
|
||||
raise ValueError("Invalid notification target. No name found.")
|
||||
|
||||
if "request" not in target:
|
||||
raise ValueError("Invalid notification target. No request details found.")
|
||||
|
||||
if "url" not in target["request"]:
|
||||
raise ValueError("Invalid notification target. No URL found.")
|
||||
|
||||
if "method" in target["request"] and target["request"]["method"].upper() not in ["POST", "PUT"]:
|
||||
raise ValueError("Invalid notification target. Invalid method found.")
|
||||
|
||||
if "type" in target["request"] and target["request"]["type"].lower() not in ["json", "form"]:
|
||||
raise ValueError("Invalid notification target. Invalid type found.")
|
||||
|
||||
if "on" in target:
|
||||
if not isinstance(target["on"], list):
|
||||
raise ValueError("Invalid notification target. Invalid 'on' event list found.")
|
||||
|
||||
for e in target["on"]:
|
||||
if e not in Notification.allowed_events:
|
||||
raise ValueError(f"Invalid notification target. Invalid event '{e}' found.")
|
||||
|
||||
if "headers" in target["request"]:
|
||||
if not isinstance(target["request"]["headers"], list):
|
||||
raise ValueError("Invalid notification target. Invalid headers list found.")
|
||||
|
||||
for h in target["request"]["headers"]:
|
||||
if "key" not in h:
|
||||
raise ValueError("Invalid notification target. No header key found.")
|
||||
if "value" not in h:
|
||||
raise ValueError("Invalid notification target. No header value found.")
|
||||
|
||||
return True
|
||||
|
||||
async def send(self, event: str, item: ItemDTO | dict) -> list[dict]:
|
||||
if len(self.targets) < 1:
|
||||
return []
|
||||
|
||||
if not isinstance(item, ItemDTO) and not isinstance(item, dict):
|
||||
LOG.debug(f"Received invalid item type '{type(item)}' with event '{event}'.")
|
||||
return []
|
||||
|
||||
tasks = []
|
||||
|
||||
for target in self.targets:
|
||||
if len(target["on"]) > 0 and event not in target["on"] and "test" != event:
|
||||
continue
|
||||
|
||||
tasks.append(self._send(event, target, item))
|
||||
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
async def _send(self, event: str, target: Target, item: ItemDTO | dict) -> dict:
|
||||
req: targetRequest = target["request"]
|
||||
|
||||
try:
|
||||
itemId = ag(item, ["id", "_id"], "??")
|
||||
except Exception:
|
||||
itemId = "??"
|
||||
|
||||
try:
|
||||
LOG.info(f"Sending Notification event '{event}' id '{itemId}' to '{target.get('name')}'.")
|
||||
reqBody = {
|
||||
"method": req["method"].upper(),
|
||||
"url": req["url"],
|
||||
"headers": {
|
||||
"User-Agent": f"YTPTube/{APP_VERSION}",
|
||||
"Content-Type": "application/json"
|
||||
if "json" == req["type"].lower()
|
||||
else "application/x-www-form-urlencoded",
|
||||
},
|
||||
}
|
||||
|
||||
if len(req["headers"]) > 0:
|
||||
for h in req["headers"]:
|
||||
reqBody["headers"][h["key"]] = h["value"]
|
||||
|
||||
reqBody["json" if "json" == req["type"].lower() else "data"] = {
|
||||
"event": event,
|
||||
"created_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"payload": item.__dict__ if isinstance(item, ItemDTO) else item,
|
||||
}
|
||||
|
||||
if "form" == req["type"].lower():
|
||||
reqBody["data"]["payload"] = self._encoder.encode(reqBody["data"]["payload"])
|
||||
|
||||
response = await self._client.request(**reqBody)
|
||||
|
||||
respData = {"url": req["url"], "status": response.status_code, "text": response.text}
|
||||
|
||||
msg = f"Notification target '{target['name']}' Responded to event '{event}' id '{itemId}' with status '{response.status_code}'."
|
||||
if self._debug and respData.get("text"):
|
||||
msg += f" body '{respData.get('text','??')}'."
|
||||
|
||||
LOG.info(msg)
|
||||
|
||||
return respData
|
||||
except Exception as e:
|
||||
LOG.error(f"Error sending Notification event '{event}' id '{itemId}' to '{target['name']}'. '{e}'.")
|
||||
return {"url": req["url"], "status": 500, "text": str(e)}
|
||||
|
||||
def emit(self, event, data, **kwargs):
|
||||
if len(self.targets) < 1:
|
||||
return False
|
||||
|
||||
if event not in self.allowed_events and "test" != event:
|
||||
return False
|
||||
|
||||
return self.send(event, data)
|
||||
32
app/library/Singleton.py
Normal file
32
app/library/Singleton.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from typing import Any, Dict
|
||||
import threading
|
||||
|
||||
|
||||
class Singleton(type):
|
||||
"""
|
||||
A metaclass that creates a Singleton base class when called.
|
||||
"""
|
||||
|
||||
_instances: Dict[type, Any] = {}
|
||||
|
||||
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
if cls not in cls._instances:
|
||||
instance = super().__call__(*args, **kwargs)
|
||||
cls._instances[cls] = instance
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
class threadSafe(type):
|
||||
"""
|
||||
A metaclass that creates a Singleton base class when called.
|
||||
"""
|
||||
|
||||
_instances: Dict[type, Any] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
with cls._lock:
|
||||
if cls not in cls._instances:
|
||||
instance = super().__call__(*args, **kwargs)
|
||||
cls._instances[cls] = instance
|
||||
return cls._instances[cls]
|
||||
|
|
@ -535,3 +535,20 @@ def arg_converter(args: str) -> dict:
|
|||
diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]]
|
||||
|
||||
return json.loads(json.dumps(diff))
|
||||
|
||||
|
||||
def validateUUID(uuid_str: str, version: int = 4) -> bool:
|
||||
"""
|
||||
Validate if the UUID is valid.
|
||||
|
||||
Args:
|
||||
uuid_str (str): UUID to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the UUID is valid.
|
||||
"""
|
||||
try:
|
||||
uuid.UUID(uuid_str, version=version)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from .ItemDTO import ItemDTO
|
||||
import httpx
|
||||
from .version import APP_VERSION
|
||||
|
||||
LOG = logging.getLogger("Webhooks")
|
||||
|
||||
|
||||
class Webhooks:
|
||||
targets: list[dict] = []
|
||||
allowed_events: tuple = (
|
||||
"added",
|
||||
"completed",
|
||||
"error",
|
||||
"not_live",
|
||||
)
|
||||
|
||||
def __init__(self, file: str):
|
||||
if os.path.exists(file):
|
||||
self.load(file)
|
||||
|
||||
def load(self, file: str):
|
||||
try:
|
||||
if os.path.getsize(file) < 3:
|
||||
raise Exception("file is empty.")
|
||||
|
||||
LOG.info(f"Loading webhooks from '{file}'.")
|
||||
from .Utils import load_file
|
||||
|
||||
(targets, status, error) = load_file(file, list)
|
||||
if not status:
|
||||
raise Exception(f"{error}")
|
||||
|
||||
for target in targets:
|
||||
LOG.info(f"Will send '{target.get('on',['all'])}' events to '{target.get('name')}'.")
|
||||
|
||||
self.targets = targets
|
||||
except Exception as e:
|
||||
LOG.error(f"Error loading webhooks from '{file}'. '{e}'")
|
||||
pass
|
||||
|
||||
async def send(self, event: str, item: ItemDTO | dict) -> list[dict]:
|
||||
if len(self.targets) == 0:
|
||||
return []
|
||||
|
||||
if not isinstance(item, ItemDTO) and not isinstance(item, dict):
|
||||
LOG.debug(f"Received invalid item type '{type(item)}' with event '{event}'.")
|
||||
return []
|
||||
|
||||
tasks = []
|
||||
|
||||
for target in self.targets:
|
||||
allowed_events = target.get("on") if "on" in target else []
|
||||
if len(allowed_events) > 0 and event not in allowed_events:
|
||||
continue
|
||||
|
||||
tasks.append(self.__send(event, target, item))
|
||||
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
async def __send(self, event: str, target: dict, item: ItemDTO | dict) -> dict:
|
||||
from .config import Config
|
||||
|
||||
req: dict = target.get("request")
|
||||
|
||||
try:
|
||||
itemId = item.get("id", item.get("_id", "??"))
|
||||
except Exception:
|
||||
itemId = "??"
|
||||
|
||||
try:
|
||||
LOG.info(f"Sending event '{event}' id '{itemId}' to '{target.get('name')}'.")
|
||||
async with httpx.AsyncClient() as client:
|
||||
request_type = req.get("type", "json")
|
||||
|
||||
reqBody = {
|
||||
"method": req.get("method", "POST"),
|
||||
"url": req.get("url"),
|
||||
"headers": {"User-Agent": f"YTPTube/{APP_VERSION}"},
|
||||
}
|
||||
|
||||
if req.get("headers", None):
|
||||
reqBody["headers"].update(req.get("headers"))
|
||||
|
||||
match request_type:
|
||||
case "json":
|
||||
reqBody["json"] = item.__dict__ if isinstance(item, ItemDTO) else item
|
||||
reqBody["headers"]["Content-Type"] = "application/json"
|
||||
case _:
|
||||
reqBody["data"] = item.__dict__ if isinstance(item, ItemDTO) else item
|
||||
reqBody["headers"]["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
|
||||
response = await client.request(**reqBody)
|
||||
|
||||
respData = {"url": req.get("url"), "status": response.status_code, "text": response.text}
|
||||
|
||||
msg = f"Webhook target '{target.get('name')}' Responded to event '{event}' id '{itemId}' with status '{response.status_code}'."
|
||||
if Config.get_instance().debug and respData.get("text"):
|
||||
msg += f" body '{respData.get('text','??')}'."
|
||||
|
||||
LOG.info(msg)
|
||||
|
||||
return respData
|
||||
except Exception as e:
|
||||
LOG.error(f"Error sending event '{event}' id '{itemId}' to '{target.get('name')}'. '{e}'")
|
||||
return {"url": req.get("url"), "status": 500, "text": str(e)}
|
||||
|
||||
def emit(self, event, data, **kwargs):
|
||||
if len(self.targets) < 1 or event not in self.allowed_events:
|
||||
return False
|
||||
|
||||
return self.send(event, data)
|
||||
|
|
@ -1,27 +1,12 @@
|
|||
import hashlib
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Optional, Dict, Tuple
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .Singleton import threadSafe
|
||||
|
||||
|
||||
class SingletonMeta(type):
|
||||
"""
|
||||
A metaclass that creates a Singleton base class when called.
|
||||
"""
|
||||
|
||||
_instances: Dict[type, Any] = {}
|
||||
_lock = threading.Lock() # Ensures thread-safe singleton creation
|
||||
|
||||
def __call__(cls, *args: Any, **kwargs: Any) -> Any:
|
||||
# Thread-safe instance creation
|
||||
with cls._lock:
|
||||
if cls not in cls._instances:
|
||||
instance = super().__call__(*args, **kwargs)
|
||||
cls._instances[cls] = instance
|
||||
return cls._instances[cls]
|
||||
|
||||
|
||||
class Cache(metaclass=SingletonMeta):
|
||||
class Cache(metaclass=threadSafe):
|
||||
def __init__(self) -> None:
|
||||
"""
|
||||
Initialize the Cache.
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from library.Emitter import Emitter
|
|||
from library.encoder import Encoder
|
||||
from library.HttpAPI import HttpAPI, LOG as http_logger
|
||||
from library.HttpSocket import HttpSocket
|
||||
from library.Webhooks import Webhooks
|
||||
from library.Notifications import Notification
|
||||
from library.PackageInstaller import PackageInstaller
|
||||
|
||||
LOG = logging.getLogger("app")
|
||||
|
|
@ -69,10 +69,8 @@ class Main:
|
|||
|
||||
self.http = HttpAPI(queue=queue, emitter=self.emitter, encoder=self.encoder, load_tasks=self.load_tasks)
|
||||
self.socket = HttpSocket(queue=queue, emitter=self.emitter, encoder=self.encoder)
|
||||
self.emitter.add_emitter(Notification().emit)
|
||||
|
||||
WebhookFile = os.path.join(self.config.config_path, "webhooks.json")
|
||||
if os.path.exists(WebhookFile):
|
||||
self.emitter.add_emitter(Webhooks(WebhookFile).emit)
|
||||
|
||||
def checkFolders(self) -> None:
|
||||
try:
|
||||
|
|
|
|||
268
ui/components/NotificationForm.vue
Normal file
268
ui/components/NotificationForm.vue
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
<template>
|
||||
<main class="columns mt-2">
|
||||
<div class="column">
|
||||
<form id="taskForm" @submit.prevent="checkInfo()">
|
||||
<div class="box">
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<div class="column is-12">
|
||||
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
|
||||
<span>{{ reference ? 'Edit' : 'Add' }}</span>
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="name">
|
||||
Target name
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress" required>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-user" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>The notification target name, this is used to identify the target in the logs and
|
||||
notifications.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="url">
|
||||
Target URL
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<input type="url" class="input" id="url" v-model="form.request.url" :disabled="addInProgress"
|
||||
required>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-link" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>The URL to send the notification to.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="method">
|
||||
Request method
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="method" class="is-fullwidth" v-model="form.request.method" :disabled="addInProgress">
|
||||
<option v-for="item, index in requestMethods" :key="`${index}-${item}`" :value="item">
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
The request method to use when sending the notification. This can be any of the standard HTTP
|
||||
methods.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-6-tablet is-12-mobile">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="type">
|
||||
Request Type
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-fullwidth">
|
||||
<select id="type" class="is-fullwidth" v-model="form.request.type" :disabled="addInProgress">
|
||||
<option v-for="item, index in requestType" :key="`${index}-${item}`" :value="item">
|
||||
{{ ucFirst(item) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
The request type to use when sending the notification. This can be <code>JSON</code> or
|
||||
<code>FORM</code> request.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12 is-clearfix">
|
||||
<div class="field">
|
||||
<label class="label is-inline" for="on">
|
||||
Select Events
|
||||
<template v-if="form.on.length > 0">
|
||||
- <NuxtLink @click="form.on = []">Clear selection</NuxtLink>
|
||||
</template>
|
||||
</label>
|
||||
<div class="control has-icons-left">
|
||||
<div class="select is-multiple is-fullwidth">
|
||||
<select id="on" class="is-fullwidth" v-model="form.on" :disabled="addInProgress" multiple>
|
||||
<option v-for="item, index in allowedEvents" :key="`${index}-${item}`" :value="item">
|
||||
{{ item }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-paper-plane" /></span>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>
|
||||
Subscribe to the events you want to listen for. When the event is triggered, the notification will
|
||||
be sent to the target URL. If no events are selected, the notification will be sent for all events.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="field">
|
||||
<label class="label is-inline is-unselectable">
|
||||
Optional Headers - <button type="button" class="has-text-link"
|
||||
@click="form.request.headers.push({ key: '', value: '' });">Add Header
|
||||
</button>
|
||||
</label>
|
||||
<div class="columns is-multiline is-mobile">
|
||||
<template v-for="_, key in form.request.headers" :key="key">
|
||||
<div class="column is-5">
|
||||
<div class="field">
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" v-model="form.request.headers[key].key"
|
||||
:disabled="addInProgress" required>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-key" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>The header key to send with the notification.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="field">
|
||||
<div class="control has-icons-left">
|
||||
<input type="text" class="input" v-model="form.request.headers[key].value"
|
||||
:disabled="addInProgress" required>
|
||||
<span class="icon is-small is-left"><i class="fa-solid fa-v" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-info" /></span>
|
||||
<span>The header value to send with the notification.</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="column is-1">
|
||||
<div class="control">
|
||||
<button type="button" class="button is-danger" @click="form.request.headers.splice(key, 1)"
|
||||
:disabled="addInProgress">
|
||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<span class="help">
|
||||
<span class="icon"><i class="fa-solid fa-exclamation" /></span>
|
||||
<span class="has-text-danger">
|
||||
If header key or value is empty, the header will not be sent.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<div class="field is-grouped is-grouped-right">
|
||||
<p class="control">
|
||||
<button class="button is-primary" :disabled="addInProgress" type="submit"
|
||||
:class="{ 'is-loading': addInProgress }" form="taskForm">
|
||||
<span class="icon"><i class="fa-solid fa-save" /></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control">
|
||||
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button">
|
||||
<span class="icon"><i class="fa-solid fa-times" /></span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const emitter = defineEmits(['cancel', 'submit']);
|
||||
const toast = useToast();
|
||||
const addInProgress = ref(false);
|
||||
const props = defineProps({
|
||||
reference: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
allowedEvents: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const form = reactive(props.item);
|
||||
const requestMethods = ['POST', 'PUT'];
|
||||
const requestType = ['json', 'form'];
|
||||
|
||||
const checkInfo = async () => {
|
||||
const required = ['name', 'request.url', 'request.method', 'request.type'];
|
||||
for (const key of required) {
|
||||
if (key.includes('.')) {
|
||||
const [parent, child] = key.split('.');
|
||||
if (!form[parent][child]) {
|
||||
toast.error(`The field ${parent}.${child} is required.`);
|
||||
return;
|
||||
}
|
||||
} else if (!form[key]) {
|
||||
toast.error(`The field ${key} is required.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(form.request.url);
|
||||
} catch (e) {
|
||||
toast.error('Invalid URL');
|
||||
return;
|
||||
}
|
||||
|
||||
// -- validate headers
|
||||
|
||||
for (const header of form.request.headers) {
|
||||
if (!header.key || !header.value) {
|
||||
form.request.headers.splice(form.request.headers.indexOf(header), 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
addInProgress.value = true;
|
||||
emitter('submit', { reference: toRaw(props.reference), item: toRaw(form) });
|
||||
}
|
||||
|
||||
</script>
|
||||
|
|
@ -270,7 +270,7 @@ const checkInfo = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
//addInProgress.value = true;
|
||||
addInProgress.value = true;
|
||||
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) });
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,13 @@
|
|||
<div class="navbar-brand pl-5">
|
||||
<NuxtLink class="navbar-item has-tooltip-bottom" to="/"
|
||||
v-tooltip="socket.isConnected ? 'Connected' : 'Connecting'">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fas fa-home"></i></span>
|
||||
<span>
|
||||
<span class="icon"> <i class="fas fa-home" /></span>
|
||||
<span :class="socket.isConnected ? 'has-text-success' : 'has-text-danger'"><b>YTPTube</b></span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="navbar-end is-flex">
|
||||
|
||||
<div class="navbar-end is-flex" style="flex-flow:wrap">
|
||||
<div class="navbar-item" v-if="socket.isConnected && config.app.has_cookies && !config.app.basic_mode">
|
||||
<button class="button is-dark" @click="checkCookies" v-tooltip="'Check youtube cookies status.'"
|
||||
:disabled="isChecking">
|
||||
|
|
@ -48,6 +47,12 @@
|
|||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item" v-if="!config.app.basic_mode" v-tooltip.bottom="'Notifications'">
|
||||
<NuxtLink class="button is-dark has-tooltip-bottom" to="/notifications">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item">
|
||||
<button class="button is-dark" @click="selectedTheme = 'light'" v-if="'dark' === selectedTheme"
|
||||
v-tooltip="'Switch to light theme'">
|
||||
|
|
@ -59,7 +64,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="navbar-item">
|
||||
<div class="navbar-item is-hidden-mobile">
|
||||
<button class="button is-dark" @click="reloadPage">
|
||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
||||
</button>
|
||||
|
|
|
|||
300
ui/pages/notifications.vue
Normal file
300
ui/pages/notifications.vue
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
<style scoped>
|
||||
table.is-fixed {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
div.table-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.is-centered {
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mt-1 columns is-multiline">
|
||||
<div class="column is-12 is-clearfix is-unselectable">
|
||||
<span class="title is-4">
|
||||
<span class="icon-text">
|
||||
<span class="icon"><i class="fa-solid fa-paper-plane" /></span>
|
||||
<span>Notifications</span>
|
||||
</span>
|
||||
</span>
|
||||
<div class="is-pulled-right" v-if="!toggleForm">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="resetForm(false); toggleForm = true"
|
||||
v-tooltip="'Add new notification target.'">
|
||||
<span class="icon"><i class="fas fa-add"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control" v-if="notifications.length > 0">
|
||||
<button class="button is-warning" @click="sendTest" v-tooltip="'Send test notification.'"
|
||||
:class="{ 'is-loading': isLoading }" :disabled="!socket.isConnected || isLoading">
|
||||
<span class="icon"><i class="fas fa-paper-plane"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
<p class="control" v-if="notifications.length > 0">
|
||||
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
|
||||
:disabled="!socket.isConnected || isLoading || notifications.length < 1">
|
||||
<span class="icon"><i class="fas fa-refresh"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="is-hidden-mobile">
|
||||
<span class="subtitle">
|
||||
Send notifications to your servers based on specified events.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="toggleForm">
|
||||
<NotificationForm :reference="targetRef" :item="target" @cancel="resetForm(true);" @submit="updateItem"
|
||||
:allowedEvents="allowedEvents" />
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!toggleForm">
|
||||
<div class="columns is-multiline" v-if="notifications && notifications.length > 0">
|
||||
<div class="column is-6" v-for="item in notifications" :key="item.id">
|
||||
<div class="card">
|
||||
<header class="card-header">
|
||||
<div class="card-header-title is-text-overflow is-block">
|
||||
{{ item.request.method.toUpperCase() }}({{ ucFirst(item.request.type) }}) @
|
||||
<NuxtLink target="_blank" :href="item.request.url">{{ item.name }}</NuxtLink>
|
||||
</div>
|
||||
<div class="card-header-icon">
|
||||
<a :href="item.url" class="has-text-primary" v-tooltip="'Copy url.'"
|
||||
@click.prevent="copyText(item.request.url)">
|
||||
<span class="icon"><i class="fa-solid fa-copy" /></span>
|
||||
</a>
|
||||
<button @click="item.raw = !item.raw">
|
||||
<span class="icon"><i class="fa-solid"
|
||||
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card-content">
|
||||
<div class="content">
|
||||
<p>
|
||||
<span class="icon"><i class="fa-solid fa-list-ul" /></span>
|
||||
<span>On: {{ join_events(item.on) }}</span>
|
||||
</p>
|
||||
<p v-if="item.request?.headers && item.request.headers.length > 0">
|
||||
<span class="icon"><i class="fa-solid fa-heading" /></span>
|
||||
<span>{{ item.request.headers.map(h => h.key).join(', ') }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-content" v-if="item?.raw">
|
||||
<div class="content">
|
||||
<pre><code>{{ filterItem(item) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-warning is-fullwidth" @click="editItem(item);">
|
||||
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-footer-item">
|
||||
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
|
||||
<span class="icon"><i class="fa-solid fa-trash" /></span>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Message title="No Endpoints" class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
|
||||
v-if="!notifications || notifications.length < 1">
|
||||
There are no notifications endpoints configured to receive web notifications.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { request } from '~/utils/index'
|
||||
|
||||
const toast = useToast()
|
||||
const config = useConfigStore()
|
||||
const socket = useSocketStore()
|
||||
|
||||
const allowedEvents = ref([])
|
||||
const notifications = ref([])
|
||||
const target = ref({})
|
||||
const targetRef = ref('')
|
||||
const toggleForm = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const initialLoad = ref(true)
|
||||
|
||||
watch(() => config.app.basic_mode, async () => {
|
||||
if (!config.app.basic_mode) {
|
||||
return
|
||||
}
|
||||
await navigateTo('/')
|
||||
})
|
||||
|
||||
watch(() => socket.isConnected, async () => {
|
||||
if (socket.isConnected && initialLoad.value) {
|
||||
await reloadContent(true)
|
||||
initialLoad.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const reloadContent = async (fromMounted = false) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request('/api/notifications')
|
||||
|
||||
if (fromMounted && !response.ok) {
|
||||
return
|
||||
}
|
||||
|
||||
notifications.value = []
|
||||
|
||||
const data = await response.json()
|
||||
if (data.length < 1) {
|
||||
return
|
||||
}
|
||||
|
||||
allowedEvents.value = data.allowedTypes
|
||||
notifications.value = data.notifications
|
||||
} catch (e) {
|
||||
if (fromMounted) {
|
||||
return
|
||||
}
|
||||
console.error(e)
|
||||
toast.error('Failed to fetch notifications.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = (closeForm = false) => {
|
||||
target.value = {
|
||||
name: '',
|
||||
on: [],
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '',
|
||||
type: 'json',
|
||||
headers: [],
|
||||
},
|
||||
}
|
||||
targetRef.value = null
|
||||
if (closeForm) {
|
||||
toggleForm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = async notifications => {
|
||||
const response = await request('/api/notifications', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(notifications),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (200 !== response.status) {
|
||||
toast.error(`Failed to update notifications. ${data.error}`);
|
||||
return false
|
||||
}
|
||||
|
||||
notifications.value = data.notifications
|
||||
return true
|
||||
}
|
||||
|
||||
const deleteItem = async item => {
|
||||
if (true !== confirm(`Are you sure you want to delete notification target (${item.name})?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = notifications.value.findIndex(i => i?.id === item.id)
|
||||
if (index > -1) {
|
||||
notifications.value.splice(index, 1)
|
||||
} else {
|
||||
toast.error('Notification target not found.')
|
||||
await reloadContent()
|
||||
return
|
||||
}
|
||||
|
||||
const status = await updateData(notifications.value)
|
||||
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Notification target deleted.')
|
||||
}
|
||||
|
||||
const updateItem = async ({ reference, item }) => {
|
||||
if (reference) {
|
||||
const index = notifications.value.findIndex(i => i?.id === reference)
|
||||
if (index > -1) {
|
||||
notifications.value[index] = item
|
||||
}
|
||||
} else {
|
||||
notifications.value.push(item)
|
||||
}
|
||||
|
||||
const status = await updateData(notifications.value)
|
||||
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`Notification target ${reference ? 'updated' : 'added'}.`)
|
||||
resetForm(true)
|
||||
}
|
||||
|
||||
|
||||
const filterItem = item => {
|
||||
const { raw, ...rest } = item
|
||||
return JSON.stringify(rest, null, 2)
|
||||
}
|
||||
|
||||
const editItem = item => {
|
||||
target.value = item
|
||||
targetRef.value = item.id
|
||||
toggleForm.value = true
|
||||
}
|
||||
|
||||
const join_events = events => (!events || events.length < 1) ? 'ALL' : events.map(e => ucFirst(e)).join(', ')
|
||||
|
||||
const sendTest = async () => {
|
||||
if (true !== confirm('Are you sure you want to send a test notification?')) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request('/api/notifications/test', { method: 'POST' })
|
||||
|
||||
if (200 !== response.status) {
|
||||
const data = await response.json()
|
||||
toast.error(`Failed to send test notification. ${data.error}`);
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Test notification sent.')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.error(`Failed to send test notification. ${e.message}`);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
|
||||
|
||||
</script>
|
||||
|
|
@ -25,7 +25,7 @@ div.is-centered {
|
|||
<div class="is-pulled-right">
|
||||
<div class="field is-grouped">
|
||||
<p class="control">
|
||||
<button class="button is-primary" @click="resetTask(false); toggleForm = !toggleForm">
|
||||
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm">
|
||||
<span class="icon"><i class="fas fa-add"></i></span>
|
||||
</button>
|
||||
</p>
|
||||
|
|
@ -46,7 +46,7 @@ div.is-centered {
|
|||
</div>
|
||||
|
||||
<div class="column is-12" v-if="toggleForm">
|
||||
<TaskForm :reference="taskRef" :task="task" @cancel="resetTask(true);" @submit="updateItem" />
|
||||
<TaskForm :reference="taskRef" :task="task" @cancel="resetForm(true);" @submit="updateItem" />
|
||||
</div>
|
||||
|
||||
<div class="column is-12" v-if="!toggleForm">
|
||||
|
|
@ -175,7 +175,7 @@ const reloadContent = async (fromMounted = false) => {
|
|||
}
|
||||
}
|
||||
|
||||
const resetTask = (closeForm = false) => {
|
||||
const resetForm = (closeForm = false) => {
|
||||
task.value = {}
|
||||
taskRef.value = null
|
||||
if (closeForm) {
|
||||
|
|
@ -243,13 +243,11 @@ const updateItem = async ({ reference, task }) => {
|
|||
}
|
||||
|
||||
toast.success('Task updated')
|
||||
resetTask(true)
|
||||
toggleForm.value = false
|
||||
resetForm(true)
|
||||
}
|
||||
|
||||
|
||||
const filterItem = item => {
|
||||
// -- remove the raw key from the item. before showing it to user
|
||||
const { raw, ...rest } = item
|
||||
return JSON.stringify(rest, null, 2)
|
||||
}
|
||||
|
|
@ -269,5 +267,4 @@ const calcPath = path => {
|
|||
}
|
||||
|
||||
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
|
||||
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Reference in a new issue