mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-12-06 21:44:13 +03:00
Improve Notifications (#16453)
* backend * frontend * add notification config at camera level * camera level notifications in dispatcher * initial onconnect * frontend * backend for suspended notifications * frontend * use base communicator * initialize all cameras in suspended array and use 0 for unsuspended * remove switch and use select for suspending in frontend * use timestamp instead of datetime * frontend tweaks * mqtt docs * fix button width * use grid for layout * use thread and queue for processing notifications with 10s timeout * clean up * move async code to main class * tweaks * docs * remove warning message
This commit is contained in:
parent
198d067e25
commit
9a0211a71c
@ -409,6 +409,7 @@ motion:
|
|||||||
mqtt_off_delay: 30
|
mqtt_off_delay: 30
|
||||||
|
|
||||||
# Optional: Notification Configuration
|
# Optional: Notification Configuration
|
||||||
|
# NOTE: Can be overridden at the camera level (except email)
|
||||||
notifications:
|
notifications:
|
||||||
# Optional: Enable notification service (default: shown below)
|
# Optional: Enable notification service (default: shown below)
|
||||||
enabled: False
|
enabled: False
|
||||||
|
|||||||
@ -341,3 +341,19 @@ the camera to be removed from the view._
|
|||||||
### `frigate/<camera_name>/birdseye_mode/state`
|
### `frigate/<camera_name>/birdseye_mode/state`
|
||||||
|
|
||||||
Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`.
|
Topic with current state of the Birdseye mode for a camera. Published values are `CONTINUOUS`, `MOTION`, `OBJECTS`.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/notifications/set`
|
||||||
|
|
||||||
|
Topic to turn notifications on and off. Expected values are `ON` and `OFF`.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/notifications/state`
|
||||||
|
|
||||||
|
Topic with current state of notifications. Published values are `ON` and `OFF`.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/notifications/suspend`
|
||||||
|
|
||||||
|
Topic to suspend notifications for a certain number of minutes. Expected value is an integer.
|
||||||
|
|
||||||
|
### `frigate/<camera_name>/notifications/suspended`
|
||||||
|
|
||||||
|
Topic with timestamp that notifications are suspended until. Published value is a UNIX timestamp, or 0 if notifications are not suspended.
|
||||||
|
|||||||
@ -17,8 +17,9 @@ import frigate.util as util
|
|||||||
from frigate.api.auth import hash_password
|
from frigate.api.auth import hash_password
|
||||||
from frigate.api.fastapi_app import create_fastapi_app
|
from frigate.api.fastapi_app import create_fastapi_app
|
||||||
from frigate.camera import CameraMetrics, PTZMetrics
|
from frigate.camera import CameraMetrics, PTZMetrics
|
||||||
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.comms.config_updater import ConfigPublisher
|
from frigate.comms.config_updater import ConfigPublisher
|
||||||
from frigate.comms.dispatcher import Communicator, Dispatcher
|
from frigate.comms.dispatcher import Dispatcher
|
||||||
from frigate.comms.event_metadata_updater import (
|
from frigate.comms.event_metadata_updater import (
|
||||||
EventMetadataPublisher,
|
EventMetadataPublisher,
|
||||||
EventMetadataTypeEnum,
|
EventMetadataTypeEnum,
|
||||||
@ -314,8 +315,14 @@ class FrigateApp:
|
|||||||
if self.config.mqtt.enabled:
|
if self.config.mqtt.enabled:
|
||||||
comms.append(MqttClient(self.config))
|
comms.append(MqttClient(self.config))
|
||||||
|
|
||||||
if self.config.notifications.enabled_in_config:
|
notification_cameras = [
|
||||||
comms.append(WebPushClient(self.config))
|
c
|
||||||
|
for c in self.config.cameras.values()
|
||||||
|
if c.enabled and c.notifications.enabled_in_config
|
||||||
|
]
|
||||||
|
|
||||||
|
if notification_cameras:
|
||||||
|
comms.append(WebPushClient(self.config, self.stop_event))
|
||||||
|
|
||||||
comms.append(WebSocketClient(self.config))
|
comms.append(WebSocketClient(self.config))
|
||||||
comms.append(self.inter_process_communicator)
|
comms.append(self.inter_process_communicator)
|
||||||
|
|||||||
21
frigate/comms/base_communicator.py
Normal file
21
frigate/comms/base_communicator.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class Communicator(ABC):
|
||||||
|
"""pub/sub model via specific protocol."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
|
"""Send data via specific protocol."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def subscribe(self, receiver: Callable) -> None:
|
||||||
|
"""Pass receiver so communicators can pass commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop the communicator."""
|
||||||
|
pass
|
||||||
@ -3,17 +3,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import Any, Callable, Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from frigate.camera import PTZMetrics
|
from frigate.camera import PTZMetrics
|
||||||
from frigate.camera.activity_manager import CameraActivityManager
|
from frigate.camera.activity_manager import CameraActivityManager
|
||||||
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.comms.config_updater import ConfigPublisher
|
from frigate.comms.config_updater import ConfigPublisher
|
||||||
|
from frigate.comms.webpush import WebPushClient
|
||||||
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
from frigate.config import BirdseyeModeEnum, FrigateConfig
|
||||||
from frigate.const import (
|
from frigate.const import (
|
||||||
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
CLEAR_ONGOING_REVIEW_SEGMENTS,
|
||||||
INSERT_MANY_RECORDINGS,
|
INSERT_MANY_RECORDINGS,
|
||||||
INSERT_PREVIEW,
|
INSERT_PREVIEW,
|
||||||
|
NOTIFICATION_TEST,
|
||||||
REQUEST_REGION_GRID,
|
REQUEST_REGION_GRID,
|
||||||
UPDATE_CAMERA_ACTIVITY,
|
UPDATE_CAMERA_ACTIVITY,
|
||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||||
@ -30,25 +32,6 @@ from frigate.util.services import restart_frigate
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class Communicator(ABC):
|
|
||||||
"""pub/sub model via specific protocol."""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
|
||||||
"""Send data via specific protocol."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def subscribe(self, receiver: Callable) -> None:
|
|
||||||
"""Pass receiver so communicators can pass commands."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def stop(self) -> None:
|
|
||||||
"""Stop the communicator."""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class Dispatcher:
|
class Dispatcher:
|
||||||
"""Handle communication between Frigate and communicators."""
|
"""Handle communication between Frigate and communicators."""
|
||||||
|
|
||||||
@ -77,18 +60,23 @@ class Dispatcher:
|
|||||||
"motion": self._on_motion_command,
|
"motion": self._on_motion_command,
|
||||||
"motion_contour_area": self._on_motion_contour_area_command,
|
"motion_contour_area": self._on_motion_contour_area_command,
|
||||||
"motion_threshold": self._on_motion_threshold_command,
|
"motion_threshold": self._on_motion_threshold_command,
|
||||||
|
"notifications": self._on_camera_notification_command,
|
||||||
"recordings": self._on_recordings_command,
|
"recordings": self._on_recordings_command,
|
||||||
"snapshots": self._on_snapshots_command,
|
"snapshots": self._on_snapshots_command,
|
||||||
"birdseye": self._on_birdseye_command,
|
"birdseye": self._on_birdseye_command,
|
||||||
"birdseye_mode": self._on_birdseye_mode_command,
|
"birdseye_mode": self._on_birdseye_mode_command,
|
||||||
}
|
}
|
||||||
self._global_settings_handlers: dict[str, Callable] = {
|
self._global_settings_handlers: dict[str, Callable] = {
|
||||||
"notifications": self._on_notification_command,
|
"notifications": self._on_global_notification_command,
|
||||||
}
|
}
|
||||||
|
|
||||||
for comm in self.comms:
|
for comm in self.comms:
|
||||||
comm.subscribe(self._receive)
|
comm.subscribe(self._receive)
|
||||||
|
|
||||||
|
self.web_push_client = next(
|
||||||
|
(comm for comm in communicators if isinstance(comm, WebPushClient)), None
|
||||||
|
)
|
||||||
|
|
||||||
def _receive(self, topic: str, payload: str) -> Optional[Any]:
|
def _receive(self, topic: str, payload: str) -> Optional[Any]:
|
||||||
"""Handle receiving of payload from communicators."""
|
"""Handle receiving of payload from communicators."""
|
||||||
|
|
||||||
@ -180,6 +168,13 @@ class Dispatcher:
|
|||||||
"snapshots": self.config.cameras[camera].snapshots.enabled,
|
"snapshots": self.config.cameras[camera].snapshots.enabled,
|
||||||
"record": self.config.cameras[camera].record.enabled,
|
"record": self.config.cameras[camera].record.enabled,
|
||||||
"audio": self.config.cameras[camera].audio.enabled,
|
"audio": self.config.cameras[camera].audio.enabled,
|
||||||
|
"notifications": self.config.cameras[camera].notifications.enabled,
|
||||||
|
"notifications_suspended": int(
|
||||||
|
self.web_push_client.suspended_cameras.get(camera, 0)
|
||||||
|
)
|
||||||
|
if self.web_push_client
|
||||||
|
and camera in self.web_push_client.suspended_cameras
|
||||||
|
else 0,
|
||||||
"autotracking": self.config.cameras[
|
"autotracking": self.config.cameras[
|
||||||
camera
|
camera
|
||||||
].onvif.autotracking.enabled,
|
].onvif.autotracking.enabled,
|
||||||
@ -192,6 +187,9 @@ class Dispatcher:
|
|||||||
json.dumps(self.embeddings_reindex.copy()),
|
json.dumps(self.embeddings_reindex.copy()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def handle_notification_test():
|
||||||
|
self.publish("notification_test", "Test notification")
|
||||||
|
|
||||||
# Dictionary mapping topic to handlers
|
# Dictionary mapping topic to handlers
|
||||||
topic_handlers = {
|
topic_handlers = {
|
||||||
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
|
INSERT_MANY_RECORDINGS: handle_insert_many_recordings,
|
||||||
@ -203,13 +201,14 @@ class Dispatcher:
|
|||||||
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
|
UPDATE_EVENT_DESCRIPTION: handle_update_event_description,
|
||||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||||
|
NOTIFICATION_TEST: handle_notification_test,
|
||||||
"restart": handle_restart,
|
"restart": handle_restart,
|
||||||
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
|
"embeddingsReindexProgress": handle_embeddings_reindex_progress,
|
||||||
"modelState": handle_model_state,
|
"modelState": handle_model_state,
|
||||||
"onConnect": handle_on_connect,
|
"onConnect": handle_on_connect,
|
||||||
}
|
}
|
||||||
|
|
||||||
if topic.endswith("set") or topic.endswith("ptz"):
|
if topic.endswith("set") or topic.endswith("ptz") or topic.endswith("suspend"):
|
||||||
try:
|
try:
|
||||||
parts = topic.split("/")
|
parts = topic.split("/")
|
||||||
if len(parts) == 3 and topic.endswith("set"):
|
if len(parts) == 3 and topic.endswith("set"):
|
||||||
@ -224,6 +223,11 @@ class Dispatcher:
|
|||||||
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
|
# example /cam_name/ptz payload=MOVE_UP|MOVE_DOWN|STOP...
|
||||||
camera_name = parts[-2]
|
camera_name = parts[-2]
|
||||||
handle_camera_command("ptz", camera_name, "", payload)
|
handle_camera_command("ptz", camera_name, "", payload)
|
||||||
|
elif len(parts) == 3 and topic.endswith("suspend"):
|
||||||
|
# example /cam_name/notifications/suspend payload=duration
|
||||||
|
camera_name = parts[-3]
|
||||||
|
command = parts[-2]
|
||||||
|
self._on_camera_notification_suspend(camera_name, payload)
|
||||||
except IndexError:
|
except IndexError:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Received invalid {topic.split('/')[-1]} command: {topic}"
|
f"Received invalid {topic.split('/')[-1]} command: {topic}"
|
||||||
@ -365,16 +369,18 @@ class Dispatcher:
|
|||||||
self.config_updater.publish(f"config/motion/{camera_name}", motion_settings)
|
self.config_updater.publish(f"config/motion/{camera_name}", motion_settings)
|
||||||
self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True)
|
self.publish(f"{camera_name}/motion_threshold/state", payload, retain=True)
|
||||||
|
|
||||||
def _on_notification_command(self, payload: str) -> None:
|
def _on_global_notification_command(self, payload: str) -> None:
|
||||||
"""Callback for notification topic."""
|
"""Callback for global notification topic."""
|
||||||
if payload != "ON" and payload != "OFF":
|
if payload != "ON" and payload != "OFF":
|
||||||
f"Received unsupported value for notification: {payload}"
|
f"Received unsupported value for all notification: {payload}"
|
||||||
return
|
return
|
||||||
|
|
||||||
notification_settings = self.config.notifications
|
notification_settings = self.config.notifications
|
||||||
logger.info(f"Setting notifications: {payload}")
|
logger.info(f"Setting all notifications: {payload}")
|
||||||
notification_settings.enabled = payload == "ON" # type: ignore[union-attr]
|
notification_settings.enabled = payload == "ON" # type: ignore[union-attr]
|
||||||
self.config_updater.publish("config/notifications", notification_settings)
|
self.config_updater.publish(
|
||||||
|
"config/notifications", {"_global_notifications": notification_settings}
|
||||||
|
)
|
||||||
self.publish("notifications/state", payload, retain=True)
|
self.publish("notifications/state", payload, retain=True)
|
||||||
|
|
||||||
def _on_audio_command(self, camera_name: str, payload: str) -> None:
|
def _on_audio_command(self, camera_name: str, payload: str) -> None:
|
||||||
@ -491,3 +497,71 @@ class Dispatcher:
|
|||||||
|
|
||||||
self.config_updater.publish(f"config/birdseye/{camera_name}", birdseye_settings)
|
self.config_updater.publish(f"config/birdseye/{camera_name}", birdseye_settings)
|
||||||
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
|
self.publish(f"{camera_name}/birdseye_mode/state", payload, retain=True)
|
||||||
|
|
||||||
|
def _on_camera_notification_command(self, camera_name: str, payload: str) -> None:
|
||||||
|
"""Callback for camera level notifications topic."""
|
||||||
|
notification_settings = self.config.cameras[camera_name].notifications
|
||||||
|
|
||||||
|
if payload == "ON":
|
||||||
|
if not self.config.cameras[camera_name].notifications.enabled_in_config:
|
||||||
|
logger.error(
|
||||||
|
"Notifications must be enabled in the config to be turned on via MQTT."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not notification_settings.enabled:
|
||||||
|
logger.info(f"Turning on notifications for {camera_name}")
|
||||||
|
notification_settings.enabled = True
|
||||||
|
if (
|
||||||
|
self.web_push_client
|
||||||
|
and camera_name in self.web_push_client.suspended_cameras
|
||||||
|
):
|
||||||
|
self.web_push_client.suspended_cameras[camera_name] = 0
|
||||||
|
elif payload == "OFF":
|
||||||
|
if notification_settings.enabled:
|
||||||
|
logger.info(f"Turning off notifications for {camera_name}")
|
||||||
|
notification_settings.enabled = False
|
||||||
|
if (
|
||||||
|
self.web_push_client
|
||||||
|
and camera_name in self.web_push_client.suspended_cameras
|
||||||
|
):
|
||||||
|
self.web_push_client.suspended_cameras[camera_name] = 0
|
||||||
|
|
||||||
|
self.config_updater.publish(
|
||||||
|
"config/notifications", {camera_name: notification_settings}
|
||||||
|
)
|
||||||
|
self.publish(f"{camera_name}/notifications/state", payload, retain=True)
|
||||||
|
self.publish(f"{camera_name}/notifications/suspended", "0", retain=True)
|
||||||
|
|
||||||
|
def _on_camera_notification_suspend(self, camera_name: str, payload: str) -> None:
|
||||||
|
"""Callback for camera level notifications suspend topic."""
|
||||||
|
try:
|
||||||
|
duration = int(payload)
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"Invalid suspension duration: {payload}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.web_push_client is None:
|
||||||
|
logger.error("WebPushClient not available for suspension")
|
||||||
|
return
|
||||||
|
|
||||||
|
notification_settings = self.config.cameras[camera_name].notifications
|
||||||
|
|
||||||
|
if not notification_settings.enabled:
|
||||||
|
logger.error(f"Notifications are not enabled for {camera_name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if duration != 0:
|
||||||
|
self.web_push_client.suspend_notifications(camera_name, duration)
|
||||||
|
else:
|
||||||
|
self.web_push_client.unsuspend_notifications(camera_name)
|
||||||
|
|
||||||
|
self.publish(
|
||||||
|
f"{camera_name}/notifications/suspended",
|
||||||
|
str(
|
||||||
|
int(self.web_push_client.suspended_cameras.get(camera_name, 0))
|
||||||
|
if camera_name in self.web_push_client.suspended_cameras
|
||||||
|
else 0
|
||||||
|
),
|
||||||
|
retain=True,
|
||||||
|
)
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from typing import Callable
|
|||||||
|
|
||||||
import zmq
|
import zmq
|
||||||
|
|
||||||
from frigate.comms.dispatcher import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
|
|
||||||
SOCKET_REP_REQ = "ipc:///tmp/cache/comms"
|
SOCKET_REP_REQ = "ipc:///tmp/cache/comms"
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from typing import Any, Callable
|
|||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
from paho.mqtt.enums import CallbackAPIVersion
|
from paho.mqtt.enums import CallbackAPIVersion
|
||||||
|
|
||||||
from frigate.comms.dispatcher import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@ -4,13 +4,17 @@ import datetime
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from py_vapid import Vapid01
|
from py_vapid import Vapid01
|
||||||
from pywebpush import WebPusher
|
from pywebpush import WebPusher
|
||||||
|
|
||||||
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.comms.config_updater import ConfigSubscriber
|
from frigate.comms.config_updater import ConfigSubscriber
|
||||||
from frigate.comms.dispatcher import Communicator
|
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
from frigate.const import CONFIG_DIR
|
from frigate.const import CONFIG_DIR
|
||||||
from frigate.models import User
|
from frigate.models import User
|
||||||
@ -18,15 +22,36 @@ from frigate.models import User
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PushNotification:
|
||||||
|
user: str
|
||||||
|
payload: dict[str, Any]
|
||||||
|
title: str
|
||||||
|
message: str
|
||||||
|
direct_url: str = ""
|
||||||
|
image: str = ""
|
||||||
|
notification_type: str = "alert"
|
||||||
|
ttl: int = 0
|
||||||
|
|
||||||
|
|
||||||
class WebPushClient(Communicator): # type: ignore[misc]
|
class WebPushClient(Communicator): # type: ignore[misc]
|
||||||
"""Frigate wrapper for webpush client."""
|
"""Frigate wrapper for webpush client."""
|
||||||
|
|
||||||
def __init__(self, config: FrigateConfig) -> None:
|
def __init__(self, config: FrigateConfig, stop_event: MpEvent) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self.claim_headers: dict[str, dict[str, str]] = {}
|
self.claim_headers: dict[str, dict[str, str]] = {}
|
||||||
self.refresh: int = 0
|
self.refresh: int = 0
|
||||||
self.web_pushers: dict[str, list[WebPusher]] = {}
|
self.web_pushers: dict[str, list[WebPusher]] = {}
|
||||||
self.expired_subs: dict[str, list[str]] = {}
|
self.expired_subs: dict[str, list[str]] = {}
|
||||||
|
self.suspended_cameras: dict[str, int] = {
|
||||||
|
c.name: 0 for c in self.config.cameras.values()
|
||||||
|
}
|
||||||
|
self.notification_queue: queue.Queue[PushNotification] = queue.Queue()
|
||||||
|
self.notification_thread = threading.Thread(
|
||||||
|
target=self._process_notifications, daemon=True
|
||||||
|
)
|
||||||
|
self.notification_thread.start()
|
||||||
|
self.stop_event = stop_event
|
||||||
|
|
||||||
if not self.config.notifications.email:
|
if not self.config.notifications.email:
|
||||||
logger.warning("Email must be provided for push notifications to be sent.")
|
logger.warning("Email must be provided for push notifications to be sent.")
|
||||||
@ -103,30 +128,144 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
|||||||
|
|
||||||
self.expired_subs = {}
|
self.expired_subs = {}
|
||||||
|
|
||||||
|
def suspend_notifications(self, camera: str, minutes: int) -> None:
|
||||||
|
"""Suspend notifications for a specific camera."""
|
||||||
|
suspend_until = int(
|
||||||
|
(datetime.datetime.now() + datetime.timedelta(minutes=minutes)).timestamp()
|
||||||
|
)
|
||||||
|
self.suspended_cameras[camera] = suspend_until
|
||||||
|
logger.info(
|
||||||
|
f"Notifications for {camera} suspended until {datetime.datetime.fromtimestamp(suspend_until).strftime('%Y-%m-%d %H:%M:%S')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def unsuspend_notifications(self, camera: str) -> None:
|
||||||
|
"""Unsuspend notifications for a specific camera."""
|
||||||
|
self.suspended_cameras[camera] = 0
|
||||||
|
logger.info(f"Notifications for {camera} unsuspended")
|
||||||
|
|
||||||
|
def is_camera_suspended(self, camera: str) -> bool:
|
||||||
|
return datetime.datetime.now().timestamp() <= self.suspended_cameras[camera]
|
||||||
|
|
||||||
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||||
"""Wrapper for publishing when client is in valid state."""
|
"""Wrapper for publishing when client is in valid state."""
|
||||||
# check for updated notification config
|
# check for updated notification config
|
||||||
_, updated_notification_config = self.config_subscriber.check_for_update()
|
_, updated_notification_config = self.config_subscriber.check_for_update()
|
||||||
|
|
||||||
if updated_notification_config:
|
if updated_notification_config:
|
||||||
self.config.notifications = updated_notification_config
|
for key, value in updated_notification_config.items():
|
||||||
|
if key == "_global_notifications":
|
||||||
|
self.config.notifications = value
|
||||||
|
|
||||||
if not self.config.notifications.enabled:
|
elif key in self.config.cameras:
|
||||||
return
|
self.config.cameras[key].notifications = value
|
||||||
|
|
||||||
if topic == "reviews":
|
if topic == "reviews":
|
||||||
self.send_alert(json.loads(payload))
|
decoded = json.loads(payload)
|
||||||
|
camera = decoded["before"]["camera"]
|
||||||
|
if not self.config.cameras[camera].notifications.enabled:
|
||||||
|
return
|
||||||
|
if self.is_camera_suspended(camera):
|
||||||
|
logger.debug(f"Notifications for {camera} are currently suspended.")
|
||||||
|
return
|
||||||
|
self.send_alert(decoded)
|
||||||
|
elif topic == "notification_test":
|
||||||
|
if not self.config.notifications.enabled:
|
||||||
|
return
|
||||||
|
self.send_notification_test()
|
||||||
|
|
||||||
def send_alert(self, payload: dict[str, any]) -> None:
|
def send_push_notification(
|
||||||
|
self,
|
||||||
|
user: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
direct_url: str = "",
|
||||||
|
image: str = "",
|
||||||
|
notification_type: str = "alert",
|
||||||
|
ttl: int = 0,
|
||||||
|
) -> None:
|
||||||
|
notification = PushNotification(
|
||||||
|
user=user,
|
||||||
|
payload=payload,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
direct_url=direct_url,
|
||||||
|
image=image,
|
||||||
|
notification_type=notification_type,
|
||||||
|
ttl=ttl,
|
||||||
|
)
|
||||||
|
self.notification_queue.put(notification)
|
||||||
|
|
||||||
|
def _process_notifications(self) -> None:
|
||||||
|
while not self.stop_event.is_set():
|
||||||
|
try:
|
||||||
|
notification = self.notification_queue.get(timeout=1.0)
|
||||||
|
self.check_registrations()
|
||||||
|
|
||||||
|
for pusher in self.web_pushers[notification.user]:
|
||||||
|
endpoint = pusher.subscription_info["endpoint"]
|
||||||
|
headers = self.claim_headers[
|
||||||
|
endpoint[: endpoint.index("/", 10)]
|
||||||
|
].copy()
|
||||||
|
headers["urgency"] = "high"
|
||||||
|
|
||||||
|
resp = pusher.send(
|
||||||
|
headers=headers,
|
||||||
|
ttl=notification.ttl,
|
||||||
|
data=json.dumps(
|
||||||
|
{
|
||||||
|
"title": notification.title,
|
||||||
|
"message": notification.message,
|
||||||
|
"direct_url": notification.direct_url,
|
||||||
|
"image": notification.image,
|
||||||
|
"id": notification.payload.get("after", {}).get(
|
||||||
|
"id", ""
|
||||||
|
),
|
||||||
|
"type": notification.notification_type,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
self.expired_subs.setdefault(notification.user, []).append(
|
||||||
|
endpoint
|
||||||
|
)
|
||||||
|
elif resp.status_code != 201:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to send notification to {notification.user} :: {resp.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing notification: {str(e)}")
|
||||||
|
|
||||||
|
def send_notification_test(self) -> None:
|
||||||
if not self.config.notifications.email:
|
if not self.config.notifications.email:
|
||||||
return
|
return
|
||||||
|
|
||||||
self.check_registrations()
|
self.check_registrations()
|
||||||
|
|
||||||
# Only notify for alerts
|
for user in self.web_pushers:
|
||||||
if payload["after"]["severity"] != "alert":
|
self.send_push_notification(
|
||||||
|
user=user,
|
||||||
|
payload={},
|
||||||
|
title="Test Notification",
|
||||||
|
message="This is a test notification from Frigate.",
|
||||||
|
direct_url="/",
|
||||||
|
notification_type="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_alert(self, payload: dict[str, Any]) -> None:
|
||||||
|
if (
|
||||||
|
not self.config.notifications.email
|
||||||
|
or payload["after"]["severity"] != "alert"
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
self.check_registrations()
|
||||||
|
|
||||||
state = payload["type"]
|
state = payload["type"]
|
||||||
|
|
||||||
# Don't notify if message is an update and important fields don't have an update
|
# Don't notify if message is an update and important fields don't have an update
|
||||||
@ -155,49 +294,21 @@ class WebPushClient(Communicator): # type: ignore[misc]
|
|||||||
|
|
||||||
# if event is ongoing open to live view otherwise open to recordings view
|
# if event is ongoing open to live view otherwise open to recordings view
|
||||||
direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}"
|
direct_url = f"/review?id={reviewId}" if state == "end" else f"/#{camera}"
|
||||||
|
ttl = 3600 if state == "end" else 0
|
||||||
|
|
||||||
for user, pushers in self.web_pushers.items():
|
for user in self.web_pushers:
|
||||||
for pusher in pushers:
|
self.send_push_notification(
|
||||||
endpoint = pusher.subscription_info["endpoint"]
|
user=user,
|
||||||
|
payload=payload,
|
||||||
# set headers for notification behavior
|
title=title,
|
||||||
headers = self.claim_headers[
|
message=message,
|
||||||
endpoint[0 : endpoint.index("/", 10)]
|
direct_url=direct_url,
|
||||||
].copy()
|
image=image,
|
||||||
headers["urgency"] = "high"
|
ttl=ttl,
|
||||||
ttl = 3600 if state == "end" else 0
|
)
|
||||||
|
|
||||||
# send message
|
|
||||||
resp = pusher.send(
|
|
||||||
headers=headers,
|
|
||||||
ttl=ttl,
|
|
||||||
data=json.dumps(
|
|
||||||
{
|
|
||||||
"title": title,
|
|
||||||
"message": message,
|
|
||||||
"direct_url": direct_url,
|
|
||||||
"image": image,
|
|
||||||
"id": reviewId,
|
|
||||||
"type": "alert",
|
|
||||||
}
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if resp.status_code == 201:
|
|
||||||
pass
|
|
||||||
elif resp.status_code == 404 or resp.status_code == 410:
|
|
||||||
# subscription is not found or has been unsubscribed
|
|
||||||
if not self.expired_subs.get(user):
|
|
||||||
self.expired_subs[user] = []
|
|
||||||
|
|
||||||
self.expired_subs[user].append(pusher.subscription_info["endpoint"])
|
|
||||||
# the subscription no longer exists and should be removed
|
|
||||||
else:
|
|
||||||
logger.warning(
|
|
||||||
f"Failed to send notification to {user} :: {resp.headers}"
|
|
||||||
)
|
|
||||||
|
|
||||||
self.cleanup_registrations()
|
self.cleanup_registrations()
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
pass
|
logger.info("Closing notification queue")
|
||||||
|
self.notification_thread.join()
|
||||||
|
|||||||
@ -15,7 +15,7 @@ from ws4py.server.wsgirefserver import (
|
|||||||
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
from ws4py.server.wsgiutils import WebSocketWSGIApplication
|
||||||
from ws4py.websocket import WebSocket as WebSocket_
|
from ws4py.websocket import WebSocket as WebSocket_
|
||||||
|
|
||||||
from frigate.comms.dispatcher import Communicator
|
from frigate.comms.base_communicator import Communicator
|
||||||
from frigate.config import FrigateConfig
|
from frigate.config import FrigateConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|||||||
@ -8,7 +8,6 @@ from .config import * # noqa: F403
|
|||||||
from .database import * # noqa: F403
|
from .database import * # noqa: F403
|
||||||
from .logger import * # noqa: F403
|
from .logger import * # noqa: F403
|
||||||
from .mqtt import * # noqa: F403
|
from .mqtt import * # noqa: F403
|
||||||
from .notification import * # noqa: F403
|
|
||||||
from .proxy import * # noqa: F403
|
from .proxy import * # noqa: F403
|
||||||
from .telemetry import * # noqa: F403
|
from .telemetry import * # noqa: F403
|
||||||
from .tls import * # noqa: F403
|
from .tls import * # noqa: F403
|
||||||
|
|||||||
@ -25,6 +25,7 @@ from .genai import GenAICameraConfig
|
|||||||
from .live import CameraLiveConfig
|
from .live import CameraLiveConfig
|
||||||
from .motion import MotionConfig
|
from .motion import MotionConfig
|
||||||
from .mqtt import CameraMqttConfig
|
from .mqtt import CameraMqttConfig
|
||||||
|
from .notification import NotificationConfig
|
||||||
from .objects import ObjectConfig
|
from .objects import ObjectConfig
|
||||||
from .onvif import OnvifConfig
|
from .onvif import OnvifConfig
|
||||||
from .record import RecordConfig
|
from .record import RecordConfig
|
||||||
@ -85,6 +86,9 @@ class CameraConfig(FrigateBaseModel):
|
|||||||
mqtt: CameraMqttConfig = Field(
|
mqtt: CameraMqttConfig = Field(
|
||||||
default_factory=CameraMqttConfig, title="MQTT configuration."
|
default_factory=CameraMqttConfig, title="MQTT configuration."
|
||||||
)
|
)
|
||||||
|
notifications: NotificationConfig = Field(
|
||||||
|
default_factory=NotificationConfig, title="Notifications configuration."
|
||||||
|
)
|
||||||
onvif: OnvifConfig = Field(
|
onvif: OnvifConfig = Field(
|
||||||
default_factory=OnvifConfig, title="Camera Onvif Configuration."
|
default_factory=OnvifConfig, title="Camera Onvif Configuration."
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,7 +2,7 @@ from typing import Optional
|
|||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
|
|
||||||
from .base import FrigateBaseModel
|
from ..base import FrigateBaseModel
|
||||||
|
|
||||||
__all__ = ["NotificationConfig"]
|
__all__ = ["NotificationConfig"]
|
||||||
|
|
||||||
@ -46,6 +46,7 @@ from .camera.detect import DetectConfig
|
|||||||
from .camera.ffmpeg import FfmpegConfig
|
from .camera.ffmpeg import FfmpegConfig
|
||||||
from .camera.genai import GenAIConfig
|
from .camera.genai import GenAIConfig
|
||||||
from .camera.motion import MotionConfig
|
from .camera.motion import MotionConfig
|
||||||
|
from .camera.notification import NotificationConfig
|
||||||
from .camera.objects import FilterConfig, ObjectConfig
|
from .camera.objects import FilterConfig, ObjectConfig
|
||||||
from .camera.record import RecordConfig, RetainModeEnum
|
from .camera.record import RecordConfig, RetainModeEnum
|
||||||
from .camera.review import ReviewConfig
|
from .camera.review import ReviewConfig
|
||||||
@ -62,7 +63,6 @@ from .database import DatabaseConfig
|
|||||||
from .env import EnvVars
|
from .env import EnvVars
|
||||||
from .logger import LoggerConfig
|
from .logger import LoggerConfig
|
||||||
from .mqtt import MqttConfig
|
from .mqtt import MqttConfig
|
||||||
from .notification import NotificationConfig
|
|
||||||
from .proxy import ProxyConfig
|
from .proxy import ProxyConfig
|
||||||
from .telemetry import TelemetryConfig
|
from .telemetry import TelemetryConfig
|
||||||
from .tls import TlsConfig
|
from .tls import TlsConfig
|
||||||
@ -332,7 +332,7 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
)
|
)
|
||||||
mqtt: MqttConfig = Field(title="MQTT configuration.")
|
mqtt: MqttConfig = Field(title="MQTT configuration.")
|
||||||
notifications: NotificationConfig = Field(
|
notifications: NotificationConfig = Field(
|
||||||
default_factory=NotificationConfig, title="Notification configuration."
|
default_factory=NotificationConfig, title="Global notification configuration."
|
||||||
)
|
)
|
||||||
proxy: ProxyConfig = Field(
|
proxy: ProxyConfig = Field(
|
||||||
default_factory=ProxyConfig, title="Proxy configuration."
|
default_factory=ProxyConfig, title="Proxy configuration."
|
||||||
@ -452,6 +452,7 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
"review": ...,
|
"review": ...,
|
||||||
"genai": ...,
|
"genai": ...,
|
||||||
"motion": ...,
|
"motion": ...,
|
||||||
|
"notifications": ...,
|
||||||
"detect": ...,
|
"detect": ...,
|
||||||
"ffmpeg": ...,
|
"ffmpeg": ...,
|
||||||
"timestamp_style": ...,
|
"timestamp_style": ...,
|
||||||
@ -527,6 +528,9 @@ class FrigateConfig(FrigateBaseModel):
|
|||||||
# set config pre-value
|
# set config pre-value
|
||||||
camera_config.audio.enabled_in_config = camera_config.audio.enabled
|
camera_config.audio.enabled_in_config = camera_config.audio.enabled
|
||||||
camera_config.record.enabled_in_config = camera_config.record.enabled
|
camera_config.record.enabled_in_config = camera_config.record.enabled
|
||||||
|
camera_config.notifications.enabled_in_config = (
|
||||||
|
camera_config.notifications.enabled
|
||||||
|
)
|
||||||
camera_config.onvif.autotracking.enabled_in_config = (
|
camera_config.onvif.autotracking.enabled_in_config = (
|
||||||
camera_config.onvif.autotracking.enabled
|
camera_config.onvif.autotracking.enabled
|
||||||
)
|
)
|
||||||
|
|||||||
@ -104,6 +104,7 @@ UPDATE_CAMERA_ACTIVITY = "update_camera_activity"
|
|||||||
UPDATE_EVENT_DESCRIPTION = "update_event_description"
|
UPDATE_EVENT_DESCRIPTION = "update_event_description"
|
||||||
UPDATE_MODEL_STATE = "update_model_state"
|
UPDATE_MODEL_STATE = "update_model_state"
|
||||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
|
UPDATE_EMBEDDINGS_REINDEX_PROGRESS = "handle_embeddings_reindex_progress"
|
||||||
|
NOTIFICATION_TEST = "notification_test"
|
||||||
|
|
||||||
# Stats Values
|
# Stats Values
|
||||||
|
|
||||||
|
|||||||
@ -53,13 +53,26 @@ function useValue(): useValueReturn {
|
|||||||
const cameraStates: WsState = {};
|
const cameraStates: WsState = {};
|
||||||
|
|
||||||
Object.entries(cameraActivity).forEach(([name, state]) => {
|
Object.entries(cameraActivity).forEach(([name, state]) => {
|
||||||
const { record, detect, snapshots, audio, autotracking } =
|
const {
|
||||||
|
record,
|
||||||
|
detect,
|
||||||
|
snapshots,
|
||||||
|
audio,
|
||||||
|
notifications,
|
||||||
|
notifications_suspended,
|
||||||
|
autotracking,
|
||||||
|
} =
|
||||||
// @ts-expect-error we know this is correct
|
// @ts-expect-error we know this is correct
|
||||||
state["config"];
|
state["config"];
|
||||||
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
cameraStates[`${name}/snapshots/state`] = snapshots ? "ON" : "OFF";
|
||||||
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
cameraStates[`${name}/audio/state`] = audio ? "ON" : "OFF";
|
||||||
|
cameraStates[`${name}/notifications/state`] = notifications
|
||||||
|
? "ON"
|
||||||
|
: "OFF";
|
||||||
|
cameraStates[`${name}/notifications/suspended`] =
|
||||||
|
notifications_suspended || 0;
|
||||||
cameraStates[`${name}/ptz_autotracker/state`] = autotracking
|
cameraStates[`${name}/ptz_autotracker/state`] = autotracking
|
||||||
? "ON"
|
? "ON"
|
||||||
: "OFF";
|
: "OFF";
|
||||||
@ -413,3 +426,39 @@ export function useTrackedObjectUpdate(): { payload: string } {
|
|||||||
} = useWs("tracked_object_update", "");
|
} = useWs("tracked_object_update", "");
|
||||||
return useDeepMemo(JSON.parse(payload as string));
|
return useDeepMemo(JSON.parse(payload as string));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useNotifications(camera: string): {
|
||||||
|
payload: ToggleableSetting;
|
||||||
|
send: (payload: string, retain?: boolean) => void;
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
value: { payload },
|
||||||
|
send,
|
||||||
|
} = useWs(`${camera}/notifications/state`, `${camera}/notifications/set`);
|
||||||
|
return { payload: payload as ToggleableSetting, send };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationSuspend(camera: string): {
|
||||||
|
payload: string;
|
||||||
|
send: (payload: number, retain?: boolean) => void;
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
value: { payload },
|
||||||
|
send,
|
||||||
|
} = useWs(
|
||||||
|
`${camera}/notifications/suspended`,
|
||||||
|
`${camera}/notifications/suspend`,
|
||||||
|
);
|
||||||
|
return { payload: payload as string, send };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotificationTest(): {
|
||||||
|
payload: string;
|
||||||
|
send: (payload: string, retain?: boolean) => void;
|
||||||
|
} {
|
||||||
|
const {
|
||||||
|
value: { payload },
|
||||||
|
send,
|
||||||
|
} = useWs("notification_test", "notification_test");
|
||||||
|
return { payload: payload as string, send };
|
||||||
|
}
|
||||||
|
|||||||
@ -111,6 +111,11 @@ export interface CameraConfig {
|
|||||||
timestamp: boolean;
|
timestamp: boolean;
|
||||||
};
|
};
|
||||||
name: string;
|
name: string;
|
||||||
|
notifications: {
|
||||||
|
enabled: boolean;
|
||||||
|
email?: string;
|
||||||
|
enabled_in_config: boolean;
|
||||||
|
};
|
||||||
objects: {
|
objects: {
|
||||||
filters: {
|
filters: {
|
||||||
[objectName: string]: {
|
[objectName: string]: {
|
||||||
@ -393,6 +398,7 @@ export interface FrigateConfig {
|
|||||||
notifications: {
|
notifications: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
enabled_in_config: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
objects: {
|
objects: {
|
||||||
|
|||||||
@ -14,24 +14,38 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
|
||||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useCallback, useContext, useEffect, useState } from "react";
|
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { LuExternalLink } from "react-icons/lu";
|
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
useNotifications,
|
||||||
|
useNotificationSuspend,
|
||||||
|
useNotificationTest,
|
||||||
|
} from "@/api/ws";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
|
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||||
|
|
||||||
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
const NOTIFICATION_SERVICE_WORKER = "notifications-worker.js";
|
||||||
|
|
||||||
type NotificationSettingsValueType = {
|
type NotificationSettingsValueType = {
|
||||||
enabled: boolean;
|
allEnabled: boolean;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
cameras: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type NotificationsSettingsViewProps = {
|
type NotificationsSettingsViewProps = {
|
||||||
@ -47,9 +61,52 @@ export default function NotificationView({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const allCameras = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(config.cameras).sort(
|
||||||
|
(aConf, bConf) => aConf.ui.order - bConf.ui.order,
|
||||||
|
);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const notificationCameras = useMemo(() => {
|
||||||
|
if (!config) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.values(config.cameras)
|
||||||
|
.filter(
|
||||||
|
(conf) =>
|
||||||
|
conf.enabled &&
|
||||||
|
conf.notifications &&
|
||||||
|
conf.notifications.enabled_in_config,
|
||||||
|
)
|
||||||
|
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const { send: sendTestNotification } = useNotificationTest();
|
||||||
|
|
||||||
// status bar
|
// status bar
|
||||||
|
|
||||||
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
|
||||||
|
const [changedValue, setChangedValue] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (changedValue) {
|
||||||
|
addMessage(
|
||||||
|
"notification_settings",
|
||||||
|
`Unsaved notification settings`,
|
||||||
|
undefined,
|
||||||
|
`notification_settings`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
removeMessage("notification_settings", `notification_settings`);
|
||||||
|
}
|
||||||
|
// we know that these deps are correct
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [changedValue]);
|
||||||
|
|
||||||
// notification key handling
|
// notification key handling
|
||||||
|
|
||||||
@ -87,7 +144,7 @@ export default function NotificationView({
|
|||||||
setRegistration(null);
|
setRegistration(null);
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
"Successfully registered for notifications. Restart to start receiving notifications.",
|
"Successfully registered for notifications. Restarting Frigate is required before any notifications (including a test notification) can be sent.",
|
||||||
{
|
{
|
||||||
position: "top-center",
|
position: "top-center",
|
||||||
},
|
},
|
||||||
@ -122,28 +179,44 @@ export default function NotificationView({
|
|||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
enabled: z.boolean(),
|
allEnabled: z.boolean(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
|
cameras: z.array(z.string()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
mode: "onChange",
|
mode: "onChange",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
enabled: config?.notifications.enabled,
|
allEnabled: config?.notifications.enabled,
|
||||||
email: config?.notifications.email,
|
email: config?.notifications.email,
|
||||||
|
cameras: config?.notifications.enabled
|
||||||
|
? []
|
||||||
|
: notificationCameras.map((c) => c.name),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const watchCameras = form.watch("cameras");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchCameras.length > 0) {
|
||||||
|
form.setValue("allEnabled", false);
|
||||||
|
}
|
||||||
|
}, [watchCameras, allCameras, form]);
|
||||||
|
|
||||||
const onCancel = useCallback(() => {
|
const onCancel = useCallback(() => {
|
||||||
if (!config) {
|
if (!config) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnsavedChanges(false);
|
setUnsavedChanges(false);
|
||||||
|
setChangedValue(false);
|
||||||
form.reset({
|
form.reset({
|
||||||
enabled: config.notifications.enabled,
|
allEnabled: config.notifications.enabled,
|
||||||
email: config.notifications.email || "",
|
email: config.notifications.email || "",
|
||||||
|
cameras: config?.notifications.enabled
|
||||||
|
? []
|
||||||
|
: notificationCameras.map((c) => c.name),
|
||||||
});
|
});
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -151,11 +224,27 @@ export default function NotificationView({
|
|||||||
|
|
||||||
const saveToConfig = useCallback(
|
const saveToConfig = useCallback(
|
||||||
async (
|
async (
|
||||||
{ enabled, email }: NotificationSettingsValueType, // values submitted via the form
|
{ allEnabled, email, cameras }: NotificationSettingsValueType, // values submitted via the form
|
||||||
) => {
|
) => {
|
||||||
|
const allCameraNames = allCameras.map((cam) => cam.name);
|
||||||
|
|
||||||
|
const enabledCameraQueries = cameras
|
||||||
|
.map((cam) => `&cameras.${cam}.notifications.enabled=True`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const disabledCameraQueries = allCameraNames
|
||||||
|
.filter((cam) => !cameras.includes(cam))
|
||||||
|
.map(
|
||||||
|
(cam) =>
|
||||||
|
`&cameras.${cam}.notifications.enabled=${allEnabled ? "True" : "False"}`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const allCameraQueries = enabledCameraQueries + disabledCameraQueries;
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.put(
|
.put(
|
||||||
`config/set?notifications.enabled=${enabled}¬ifications.email=${email}`,
|
`config/set?notifications.enabled=${allEnabled ? "True" : "False"}¬ifications.email=${email}${allCameraQueries}`,
|
||||||
{
|
{
|
||||||
requires_restart: 0,
|
requires_restart: 0,
|
||||||
},
|
},
|
||||||
@ -182,7 +271,7 @@ export default function NotificationView({
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[updateConfig, setIsLoading],
|
[updateConfig, setIsLoading, allCameras],
|
||||||
);
|
);
|
||||||
|
|
||||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
@ -195,149 +284,249 @@ export default function NotificationView({
|
|||||||
<div className="flex size-full flex-col md:flex-row">
|
<div className="flex size-full flex-col md:flex-row">
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
<div className="scrollbar-container order-last mb-10 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0">
|
||||||
<Heading as="h3" className="my-2">
|
<div className="grid w-full grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
Notification Settings
|
<div className="col-span-1">
|
||||||
</Heading>
|
<Heading as="h3" className="my-2">
|
||||||
|
Notification Settings
|
||||||
|
</Heading>
|
||||||
|
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
|
||||||
<p>
|
<p>
|
||||||
Frigate can natively send push notifications to your device when
|
Frigate can natively send push notifications to your device
|
||||||
it is running in the browser or installed as a PWA.
|
when it is running in the browser or installed as a PWA.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center text-primary">
|
<div className="flex items-center text-primary">
|
||||||
<Link
|
<Link
|
||||||
to="https://docs.frigate.video/configuration/notifications"
|
to="https://docs.frigate.video/configuration/notifications"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="inline"
|
className="inline"
|
||||||
>
|
>
|
||||||
Read the Documentation{" "}
|
Read the Documentation{" "}
|
||||||
<LuExternalLink className="ml-2 inline-flex size-3" />
|
<LuExternalLink className="ml-2 inline-flex size-3" />
|
||||||
</Link>
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="mt-2 space-y-6"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="email"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Entering a valid email is required, as this is used by
|
||||||
|
the push server in case problems occur.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="cameras"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
{allCameras && allCameras?.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="mb-2">
|
||||||
|
<FormLabel className="flex flex-row items-center text-base">
|
||||||
|
Cameras
|
||||||
|
</FormLabel>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allEnabled"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FilterSwitch
|
||||||
|
label="All Cameras"
|
||||||
|
isChecked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setChangedValue(true);
|
||||||
|
if (checked) {
|
||||||
|
form.setValue("cameras", []);
|
||||||
|
}
|
||||||
|
field.onChange(checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{allCameras?.map((camera) => (
|
||||||
|
<FilterSwitch
|
||||||
|
key={camera.name}
|
||||||
|
label={camera.name.replaceAll("_", " ")}
|
||||||
|
isChecked={field.value?.includes(camera.name)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setChangedValue(true);
|
||||||
|
let newCameras;
|
||||||
|
if (checked) {
|
||||||
|
newCameras = [
|
||||||
|
...field.value,
|
||||||
|
camera.name,
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
newCameras = field.value?.filter(
|
||||||
|
(value) => value !== camera.name,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
field.onChange(newCameras);
|
||||||
|
form.setValue("allEnabled", false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="font-normal text-destructive">
|
||||||
|
No cameras available.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
Select the cameras to enable notifications for.
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[50%]">
|
||||||
|
<Button
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label="Cancel"
|
||||||
|
onClick={onCancel}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="select"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex flex-1"
|
||||||
|
aria-label="Save"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ActivityIndicator />
|
||||||
|
<span>Saving...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Form {...form}>
|
<div className="col-span-1">
|
||||||
<form
|
<div className="mt-4 gap-2 space-y-6">
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
<div className="flex flex-col gap-2 md:max-w-[50%]">
|
||||||
className="mt-2 space-y-6"
|
<Separator className="my-2 flex bg-secondary md:hidden" />
|
||||||
>
|
<Heading as="h4" className="my-2">
|
||||||
<FormField
|
Device-Specific Settings
|
||||||
control={form.control}
|
</Heading>
|
||||||
name="enabled"
|
<Button
|
||||||
render={({ field }) => (
|
aria-label="Register or unregister notifications for this device"
|
||||||
<FormItem>
|
disabled={
|
||||||
<FormControl>
|
!config?.notifications.enabled || publicKey == undefined
|
||||||
<div className="flex flex-row items-center justify-start gap-2">
|
}
|
||||||
<Label className="cursor-pointer" htmlFor="auto-live">
|
onClick={() => {
|
||||||
Notifications
|
if (registration == null) {
|
||||||
</Label>
|
Notification.requestPermission().then((permission) => {
|
||||||
<Switch
|
if (permission === "granted") {
|
||||||
id="auto-live"
|
navigator.serviceWorker
|
||||||
checked={field.value}
|
.register(NOTIFICATION_SERVICE_WORKER)
|
||||||
onCheckedChange={(checked) => {
|
.then((registration) => {
|
||||||
return field.onChange(checked);
|
setRegistration(registration);
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="email"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Email</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
|
|
||||||
placeholder="example@email.com"
|
|
||||||
{...field}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormDescription>
|
|
||||||
Entering a valid email is required, as this is used by the
|
|
||||||
push server in case problems occur.
|
|
||||||
</FormDescription>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
|
|
||||||
<Button
|
|
||||||
className="flex flex-1"
|
|
||||||
aria-label="Cancel"
|
|
||||||
onClick={onCancel}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="select"
|
|
||||||
disabled={isLoading}
|
|
||||||
className="flex flex-1"
|
|
||||||
aria-label="Save"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<ActivityIndicator />
|
|
||||||
<span>Saving...</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
"Save"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-6">
|
if (registration.active) {
|
||||||
<div className="space-y-3">
|
subscribeToNotifications(registration);
|
||||||
<Separator className="my-2 flex bg-secondary" />
|
} else {
|
||||||
<Button
|
setTimeout(
|
||||||
aria-label="Register or unregister notifications for this device"
|
() =>
|
||||||
disabled={
|
subscribeToNotifications(registration),
|
||||||
!config?.notifications.enabled || publicKey == undefined
|
1000,
|
||||||
}
|
);
|
||||||
onClick={() => {
|
}
|
||||||
if (registration == null) {
|
});
|
||||||
Notification.requestPermission().then((permission) => {
|
}
|
||||||
if (permission === "granted") {
|
});
|
||||||
navigator.serviceWorker
|
} else {
|
||||||
.register(NOTIFICATION_SERVICE_WORKER)
|
registration.pushManager
|
||||||
.then((registration) => {
|
.getSubscription()
|
||||||
setRegistration(registration);
|
.then((pushSubscription) => {
|
||||||
|
pushSubscription?.unsubscribe();
|
||||||
if (registration.active) {
|
registration.unregister();
|
||||||
subscribeToNotifications(registration);
|
setRegistration(null);
|
||||||
} else {
|
removeMessage(
|
||||||
setTimeout(
|
"notification_settings",
|
||||||
() => subscribeToNotifications(registration),
|
"registration",
|
||||||
1000,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
}}
|
||||||
} else {
|
>
|
||||||
registration.pushManager
|
{`${registration != null ? "Unregister" : "Register"} for notifications on this device`}
|
||||||
.getSubscription()
|
</Button>
|
||||||
.then((pushSubscription) => {
|
{registration != null && registration.active && (
|
||||||
pushSubscription?.unsubscribe();
|
<Button
|
||||||
registration.unregister();
|
aria-label="Send a test notification"
|
||||||
setRegistration(null);
|
onClick={() => sendTestNotification("notification_test")}
|
||||||
removeMessage("notification_settings", "registration");
|
>
|
||||||
});
|
Send a test notification
|
||||||
}
|
</Button>
|
||||||
}}
|
)}
|
||||||
>
|
</div>
|
||||||
{`${registration != null ? "Unregister" : "Register"} for notifications on this device`}
|
</div>
|
||||||
</Button>
|
{notificationCameras.length > 0 && (
|
||||||
|
<div className="mt-4 gap-2 space-y-6">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Separator className="my-2 flex bg-secondary" />
|
||||||
|
<Heading as="h4" className="my-2">
|
||||||
|
Global Settings
|
||||||
|
</Heading>
|
||||||
|
<div className="max-w-xl">
|
||||||
|
<div className="mb-5 mt-2 flex flex-col gap-2 text-sm text-primary-variant">
|
||||||
|
<p>
|
||||||
|
Temporarily suspend notifications for specific cameras
|
||||||
|
on all registered devices.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex max-w-2xl flex-col gap-2.5">
|
||||||
|
<div className="rounded-lg bg-secondary p-5">
|
||||||
|
<div className="grid gap-6">
|
||||||
|
{notificationCameras.map((item) => (
|
||||||
|
<CameraNotificationSwitch
|
||||||
|
config={config}
|
||||||
|
camera={item.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -345,3 +534,110 @@ export default function NotificationView({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CameraNotificationSwitchProps = {
|
||||||
|
config?: FrigateConfig;
|
||||||
|
camera: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CameraNotificationSwitch({
|
||||||
|
config,
|
||||||
|
camera,
|
||||||
|
}: CameraNotificationSwitchProps) {
|
||||||
|
const { payload: notificationState, send: sendNotification } =
|
||||||
|
useNotifications(camera);
|
||||||
|
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
|
||||||
|
useNotificationSuspend(camera);
|
||||||
|
const [isSuspended, setIsSuspended] = useState<boolean>(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (notificationSuspendUntil) {
|
||||||
|
setIsSuspended(
|
||||||
|
notificationSuspendUntil !== "0" || notificationState === "OFF",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [notificationSuspendUntil, notificationState]);
|
||||||
|
|
||||||
|
const handleSuspend = (duration: string) => {
|
||||||
|
setIsSuspended(true);
|
||||||
|
if (duration == "off") {
|
||||||
|
sendNotification("OFF");
|
||||||
|
} else {
|
||||||
|
sendNotificationSuspend(parseInt(duration));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelSuspension = () => {
|
||||||
|
sendNotification("ON");
|
||||||
|
sendNotificationSuspend(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSuspendedUntil = (timestamp: string) => {
|
||||||
|
if (timestamp === "0") return "Frigate restarts.";
|
||||||
|
|
||||||
|
return formatUnixTimestampToDateTime(parseInt(timestamp), {
|
||||||
|
time_style: "medium",
|
||||||
|
date_style: "medium",
|
||||||
|
timezone: config?.ui.timezone,
|
||||||
|
strftime_fmt: `%b %d, ${config?.ui.time_format == "24hour" ? "%H:%M" : "%I:%M %p"}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex flex-col items-start justify-start">
|
||||||
|
<div className="flex flex-row items-center justify-start gap-3">
|
||||||
|
{!isSuspended ? (
|
||||||
|
<LuCheck className="size-6 text-success" />
|
||||||
|
) : (
|
||||||
|
<LuX className="size-6 text-danger" />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Label
|
||||||
|
className="text-md cursor-pointer capitalize text-primary"
|
||||||
|
htmlFor="camera"
|
||||||
|
>
|
||||||
|
{camera.replaceAll("_", " ")}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{!isSuspended ? (
|
||||||
|
<div className="flex flex-row items-center gap-2 text-sm text-success">
|
||||||
|
Notifications Active
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-row items-center gap-2 text-sm text-danger">
|
||||||
|
Notifications suspended until{" "}
|
||||||
|
{formatSuspendedUntil(notificationSuspendUntil)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isSuspended ? (
|
||||||
|
<Select onValueChange={handleSuspend}>
|
||||||
|
<SelectTrigger className="w-auto">
|
||||||
|
<SelectValue placeholder="Suspend" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5">Suspend for 5 minutes</SelectItem>
|
||||||
|
<SelectItem value="10">Suspend for 10 minutes</SelectItem>
|
||||||
|
<SelectItem value="30">Suspend for 30 minutes</SelectItem>
|
||||||
|
<SelectItem value="60">Suspend for 1 hour</SelectItem>
|
||||||
|
<SelectItem value="840">Suspend for 12 hours</SelectItem>
|
||||||
|
<SelectItem value="1440">Suspend for 24 hours</SelectItem>
|
||||||
|
<SelectItem value="off">Suspend until restart</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancelSuspension}
|
||||||
|
>
|
||||||
|
Cancel Suspension
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user