mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-24 07:24:42 +03:00
Add a notice registry and System Health tab (#24178)
* add a notice registry and System Health tab Problems Frigate detects on its own (ffmpeg crash loops, stuck detectors, failed model downloads, recordings deleted before their retention period) only ever existed as log lines. This adds a `NoticeRegistry` in the main process backed by two tables, an `update_notice` IPC topic so producers in other processes can reach it through the dispatcher, an admin-only API and websocket topic, and a Health tab that lists them. Kinds declare their own mode, severity, and category in one place: state notices are resolved by their producer, event notices are dismissed by the user. * treat a prerelease as behind its final release * fix notices clearing early * rename menu items and update docs * don't resolve the update notice on a failed version lookup
This commit is contained in:
committed by
Nicolas Mowen
parent
af60d2db48
commit
f3a31e2fb4
@@ -11,6 +11,7 @@ from peewee import IntegrityError
|
||||
from frigate.camera import PTZMetrics
|
||||
from frigate.camera.activity_manager import AudioActivityManager, CameraActivityManager
|
||||
from frigate.comms.base_communicator import Communicator
|
||||
from frigate.comms.mqtt import MqttClient
|
||||
from frigate.comms.runtime_state import RuntimeStatePersistence
|
||||
from frigate.comms.webpush import WebPushClient
|
||||
from frigate.config import (
|
||||
@@ -40,10 +41,12 @@ from frigate.const import (
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_JOB_STATE,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_NOTICE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
from frigate.models import Event, Previews, Recordings, ReviewSegment
|
||||
from frigate.notices.registry import NoticeRegistry
|
||||
from frigate.ptz.onvif import OnvifCommandEnum, OnvifController
|
||||
from frigate.types import ModelStatusTypesEnum, TrackedObjectUpdateTypesEnum
|
||||
from frigate.util.object import get_camera_regions_grid
|
||||
@@ -67,12 +70,18 @@ class Dispatcher:
|
||||
onvif: OnvifController,
|
||||
ptz_metrics: dict[str, PTZMetrics],
|
||||
communicators: list[Communicator],
|
||||
notice_registry: NoticeRegistry | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.config_updater = config_updater
|
||||
self.onvif = onvif
|
||||
self.ptz_metrics = ptz_metrics
|
||||
self.comms = communicators
|
||||
self.notice_registry = notice_registry
|
||||
|
||||
if notice_registry is not None:
|
||||
notice_registry.subscribe(self._publish_notices)
|
||||
|
||||
self.camera_activity = CameraActivityManager(config, self.publish)
|
||||
self.audio_activity = AudioActivityManager(config, self.publish)
|
||||
self.model_state: dict[str, ModelStatusTypesEnum] = {}
|
||||
@@ -212,6 +221,10 @@ class Dispatcher:
|
||||
False,
|
||||
)
|
||||
publish("birdseye_layout", json.dumps(self.birdseye_layout.copy()), False)
|
||||
|
||||
if self.notice_registry is not None:
|
||||
publish("notices", json.dumps(self.notice_registry.active()), False)
|
||||
|
||||
publish("audio_detections", json.dumps(audio_detections), False)
|
||||
publish(
|
||||
"profile/state",
|
||||
@@ -339,6 +352,29 @@ class Dispatcher:
|
||||
self.model_state[model] = ModelStatusTypesEnum[state]
|
||||
self.publish("model_state", json.dumps(self.model_state))
|
||||
|
||||
def handle_update_notice() -> None:
|
||||
if self.notice_registry is None or not isinstance(payload, dict):
|
||||
return
|
||||
|
||||
try:
|
||||
action = payload.get("action")
|
||||
kind = payload.get("kind")
|
||||
scope = payload.get("scope")
|
||||
|
||||
if not isinstance(kind, str):
|
||||
logger.warning("Ignoring notice update without a kind")
|
||||
elif action == "raise":
|
||||
self.notice_registry.raise_notice(
|
||||
kind, scope=scope, params=payload.get("params") or {}
|
||||
)
|
||||
elif action == "resolve":
|
||||
self.notice_registry.resolve(kind, scope)
|
||||
else:
|
||||
logger.warning("Ignoring notice update with action %s", action)
|
||||
except Exception:
|
||||
# a raise here would kill the REP thread for every process
|
||||
logger.exception("Failed to apply notice update")
|
||||
|
||||
def handle_model_state() -> None:
|
||||
self.publish("model_state", json.dumps(self.model_state.copy()))
|
||||
|
||||
@@ -406,6 +442,7 @@ class Dispatcher:
|
||||
UPDATE_REVIEW_DESCRIPTION: handle_update_review_description,
|
||||
UPDATE_MODEL_STATE: handle_update_model_state,
|
||||
UPDATE_JOB_STATE: handle_update_job_state,
|
||||
UPDATE_NOTICE: handle_update_notice,
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS: handle_update_embeddings_reindex_progress,
|
||||
UPDATE_BIRDSEYE_LAYOUT: handle_update_birdseye_layout,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE: handle_update_audio_transcription_state,
|
||||
@@ -464,6 +501,23 @@ class Dispatcher:
|
||||
for comm in self.comms:
|
||||
comm.publish(topic, payload, retain)
|
||||
|
||||
def publish_local(self, topic: str, payload: Any) -> None:
|
||||
"""Publish to every communicator except MQTT.
|
||||
|
||||
Used for topics whose external schema is not settled yet.
|
||||
"""
|
||||
for comm in self.comms:
|
||||
if isinstance(comm, MqttClient):
|
||||
continue
|
||||
|
||||
comm.publish(topic, payload, False)
|
||||
|
||||
def _publish_notices(self) -> None:
|
||||
if self.notice_registry is None:
|
||||
return
|
||||
|
||||
self.publish_local("notices", json.dumps(self.notice_registry.active()))
|
||||
|
||||
def stop(self) -> None:
|
||||
self.camera_activity.stop()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ from frigate.const import (
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_EVENT_DESCRIPTION,
|
||||
UPDATE_MODEL_STATE,
|
||||
UPDATE_NOTICE,
|
||||
UPDATE_REVIEW_DESCRIPTION,
|
||||
UPSERT_REVIEW_SEGMENT,
|
||||
)
|
||||
@@ -56,6 +57,7 @@ _WS_BLOCKED_TOPICS = frozenset(
|
||||
UPDATE_EMBEDDINGS_REINDEX_PROGRESS,
|
||||
UPDATE_BIRDSEYE_LAYOUT,
|
||||
UPDATE_AUDIO_TRANSCRIPTION_STATE,
|
||||
UPDATE_NOTICE,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -158,6 +160,16 @@ _WS_UNRESTRICTED_ONLY_TOPICS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Topics only an admin connection may receive. unrestricted_only is not
|
||||
# enough: the built-in viewer role has an empty camera allow-list, which the
|
||||
# camera policy treats as full access, while the REST side of these topics
|
||||
# is admin-only.
|
||||
_WS_ADMIN_ONLY_TOPICS = frozenset(
|
||||
{
|
||||
"notices",
|
||||
}
|
||||
)
|
||||
|
||||
# Topics whose payload (parsed as JSON) names a single owning camera at the
|
||||
# given key path. Used to scope events, reviews, triggers, etc.
|
||||
_WS_PAYLOAD_CAMERA_TOPICS: dict[str, tuple[str, ...]] = {
|
||||
@@ -280,6 +292,7 @@ def _classify_outbound(
|
||||
- "global" : send to every authenticated client
|
||||
- "drop" : send to nobody (fail-closed for unknowns)
|
||||
- "unrestricted_only" : send only to admin/full-access roles
|
||||
- "admin_only" : send only to connections with the admin role
|
||||
- "camera" : extra is the owning camera name
|
||||
- "payload_camera" : extra is the JSON key path to the camera name
|
||||
- "reshape_by_camera_key"
|
||||
@@ -288,6 +301,8 @@ def _classify_outbound(
|
||||
"""
|
||||
if topic in _WS_GLOBAL_OUTBOUND_TOPICS:
|
||||
return ("global", None)
|
||||
if topic in _WS_ADMIN_ONLY_TOPICS:
|
||||
return ("admin_only", None)
|
||||
if topic in _WS_UNRESTRICTED_ONLY_TOPICS:
|
||||
return ("unrestricted_only", None)
|
||||
if topic in _WS_RESHAPE_BY_CAMERA_KEY_TOPICS:
|
||||
@@ -391,6 +406,9 @@ def _materialize_for_ws(
|
||||
if kind == "unrestricted_only":
|
||||
return full_message if _ws_is_unrestricted(ws, config) else None
|
||||
|
||||
if kind == "admin_only":
|
||||
return full_message if "admin" in _ws_valid_roles(ws, config) else None
|
||||
|
||||
if kind == "camera":
|
||||
return full_message if ws_has_camera_access(ws, extra, config) else None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user