mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 04:38:59 +03:00
* Notice and status bar improvements Status bar problems added in the same pass got the same `Date.now()` id and overwrote each other, so usually only one showed. Messages now fall back to their text as the id. The desktop status bar shows the most severe message with a count of the rest that opens a popover listing all of them, and the mobile drawer stacks them vertically instead of placing them side by side. Dismissing a notice hid it for good, so a detector that restarted again after a dismissal was never shown. Dismiss is replaced by acknowledge, which hides a notice until it happens again, and mute, which hides it permanently. Kinds that never repeat (config and stream checks, the update notice) can only be muted. `reopen_at_count` is removed since acknowledge covers the failed login case. * move camera CPU warnings to notices High ffmpeg and detect CPU warnings sat in the status bar with no way to dismiss them. They're now `ffmpeg_high_cpu` and `detect_high_cpu` notices, raised per episode by the same tracker as skipped detections. Also stop failed login attempts held from before an acknowledgement from reopening the notice. * fix mypy and handle missing cpu stats in notices
440 lines
14 KiB
Python
440 lines
14 KiB
Python
"""Registry of notices and per-kind occurrence counts."""
|
|
|
|
import logging
|
|
import threading
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, cast
|
|
|
|
from frigate.const import REPLAY_CAMERA_PREFIX
|
|
from frigate.models import Notice, NoticeStats
|
|
from frigate.notices.types import CHECK_KINDS, NOTICE_KINDS, SEVERITY_ORDER, notice_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class _HeldRepeats:
|
|
kind: str
|
|
count: int
|
|
last_seen: float
|
|
params: dict[str, Any]
|
|
|
|
|
|
class NoticeRegistry:
|
|
"""Owns the notice tables. Lives in the main process only.
|
|
|
|
Producers call the functions in frigate.notices, which reach this object
|
|
directly in the main process and through the dispatcher everywhere else.
|
|
It holds no rules for particular kinds; those live in NOTICE_KINDS.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._lock = threading.Lock()
|
|
self._listeners: list[Callable[[], None]] = []
|
|
|
|
# row id -> repeats of a batch_repeats kind that flush() writes
|
|
self._held: dict[str, _HeldRepeats] = {}
|
|
|
|
def subscribe(self, listener: Callable[[], None]) -> None:
|
|
"""Call listener after every change to the notices."""
|
|
self._listeners.append(listener)
|
|
|
|
def _notify(self) -> None:
|
|
for listener in self._listeners:
|
|
try:
|
|
listener()
|
|
except Exception:
|
|
logger.exception("Notice listener failed")
|
|
|
|
def apply(self, update: dict[str, Any]) -> None:
|
|
"""Apply one update message built by frigate.notices."""
|
|
kind = update.get("kind")
|
|
|
|
if not isinstance(kind, str):
|
|
logger.warning("Ignoring notice update without a kind")
|
|
return
|
|
|
|
match update.get("action"):
|
|
case "raise":
|
|
self.raise_notice(
|
|
kind, scope=update.get("scope"), params=update.get("params")
|
|
)
|
|
case "resolve":
|
|
self.resolve(kind, update.get("scope"))
|
|
case "resolve_kind":
|
|
self.resolve_kind(kind)
|
|
case action:
|
|
logger.warning("Ignoring notice update with action %s", action)
|
|
|
|
def raise_notice(
|
|
self,
|
|
kind: str,
|
|
*,
|
|
scope: str | None = None,
|
|
params: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Insert a notice or count another occurrence of it.
|
|
|
|
Another occurrence shows an acknowledged notice again. A muted notice
|
|
stays hidden.
|
|
"""
|
|
definition = NOTICE_KINDS.get(kind)
|
|
|
|
if definition is None:
|
|
logger.warning("Ignoring notice of unknown kind %s", kind)
|
|
return
|
|
|
|
if (
|
|
definition.category == "camera"
|
|
and scope
|
|
and scope.startswith(REPLAY_CAMERA_PREFIX)
|
|
):
|
|
return
|
|
|
|
now = datetime.now().timestamp()
|
|
row_id = notice_id(kind, scope)
|
|
params = params or {}
|
|
|
|
with self._lock:
|
|
existing = Notice.get_or_none(Notice.id == row_id)
|
|
|
|
if existing is None:
|
|
# repeats held for a row that has since been deleted are stale
|
|
self._held.pop(row_id, None)
|
|
Notice.create(
|
|
id=row_id,
|
|
kind=kind,
|
|
scope=scope,
|
|
params=params,
|
|
first_seen=now,
|
|
last_seen=now,
|
|
count=1,
|
|
)
|
|
self._bump_occurrences(kind, 1, now)
|
|
|
|
if definition.keep_latest is not None:
|
|
self._prune(kind, definition.keep_latest)
|
|
elif not definition.counts_repeats:
|
|
return
|
|
elif definition.batch_repeats:
|
|
held = self._held.setdefault(row_id, _HeldRepeats(kind, 0, now, params))
|
|
held.count += 1
|
|
held.last_seen = now
|
|
held.params = params
|
|
return
|
|
else:
|
|
self._write_repeats(existing, kind, 1, now, params)
|
|
|
|
self._notify()
|
|
|
|
def flush(self) -> None:
|
|
"""Write the repeats that batch_repeats kinds held back."""
|
|
with self._lock:
|
|
held, self._held = self._held, {}
|
|
written = False
|
|
|
|
for row_id, repeats in held.items():
|
|
row = Notice.get_or_none(Notice.id == row_id)
|
|
|
|
# resolved while its repeats waited
|
|
if row is None:
|
|
continue
|
|
|
|
self._write_repeats(
|
|
row,
|
|
repeats.kind,
|
|
repeats.count,
|
|
repeats.last_seen,
|
|
repeats.params,
|
|
)
|
|
written = True
|
|
|
|
if written:
|
|
self._notify()
|
|
|
|
def resolve(self, kind: str, scope: str | None = None) -> None:
|
|
"""Delete a notice if present. Safe to call when it is absent."""
|
|
with self._lock:
|
|
deleted = (
|
|
Notice.delete().where(Notice.id == notice_id(kind, scope)).execute()
|
|
)
|
|
|
|
if deleted:
|
|
self._notify()
|
|
|
|
def resolve_kind(self, kind: str) -> None:
|
|
"""Delete every notice of a kind."""
|
|
with self._lock:
|
|
deleted = Notice.delete().where(Notice.kind == kind).execute()
|
|
|
|
if deleted:
|
|
self._notify()
|
|
|
|
def resolve_camera(self, camera: str) -> None:
|
|
"""Drop the notices and check mutes of a camera being deleted."""
|
|
camera_kinds = [
|
|
key
|
|
for key, definition in NOTICE_KINDS.items()
|
|
if definition.category == "camera"
|
|
]
|
|
|
|
with self._lock:
|
|
deleted = (
|
|
Notice.delete()
|
|
.where(
|
|
Notice.kind.in_(camera_kinds),
|
|
Notice.scope == camera,
|
|
)
|
|
.execute()
|
|
)
|
|
|
|
# a stream id names its camera first; a config id ends with camera.<name>
|
|
for check in self.muted_checks():
|
|
check_id = check["id"]
|
|
|
|
if check_id.startswith(f"stream:{camera}:") or (
|
|
check_id.startswith("config:")
|
|
and check_id.endswith(f":camera.{camera}")
|
|
):
|
|
Notice.delete_by_id(check_id)
|
|
|
|
if deleted:
|
|
self._notify()
|
|
|
|
def acknowledge(self, row_id: str) -> bool:
|
|
"""Hide a notice until it happens again.
|
|
|
|
Returns False for an unknown id or a kind that never repeats, such as
|
|
a check row or the update notice.
|
|
"""
|
|
with self._lock:
|
|
existing = Notice.get_or_none(Notice.id == row_id)
|
|
|
|
if existing is None:
|
|
return False
|
|
|
|
definition = NOTICE_KINDS.get(existing.kind)
|
|
|
|
if definition is None or not definition.counts_repeats:
|
|
return False
|
|
|
|
if existing.acknowledged_at is not None or existing.muted_at is not None:
|
|
return True
|
|
|
|
Notice.update(acknowledged_at=datetime.now().timestamp()).where(
|
|
Notice.id == row_id
|
|
).execute()
|
|
NoticeStats.update(acknowledgements=NoticeStats.acknowledgements + 1).where(
|
|
NoticeStats.kind == existing.kind
|
|
).execute()
|
|
|
|
self._notify()
|
|
return True
|
|
|
|
def mute(self, row_id: str) -> bool:
|
|
"""Hide a notice or check row for good. Returns False for an unknown id."""
|
|
now = datetime.now().timestamp()
|
|
|
|
with self._lock:
|
|
existing = Notice.get_or_none(Notice.id == row_id)
|
|
|
|
if existing is None:
|
|
# a check row gets a notice row only once it is muted
|
|
kind, _, scope = row_id.partition(":")
|
|
|
|
if kind not in CHECK_KINDS or not scope:
|
|
return False
|
|
|
|
Notice.create(
|
|
id=row_id,
|
|
kind=kind,
|
|
scope=scope,
|
|
params={},
|
|
first_seen=now,
|
|
last_seen=now,
|
|
count=1,
|
|
muted_at=now,
|
|
)
|
|
return True
|
|
|
|
if existing.muted_at is not None:
|
|
return True
|
|
|
|
if existing.kind not in NOTICE_KINDS:
|
|
return False
|
|
|
|
Notice.update(acknowledged_at=None, muted_at=now).where(
|
|
Notice.id == row_id
|
|
).execute()
|
|
NoticeStats.update(mutes=NoticeStats.mutes + 1).where(
|
|
NoticeStats.kind == existing.kind
|
|
).execute()
|
|
|
|
self._notify()
|
|
return True
|
|
|
|
def unhide(self, row_id: str) -> bool:
|
|
"""Show an acknowledged or muted row again. Returns False for an unknown id."""
|
|
with self._lock:
|
|
existing = Notice.get_or_none(Notice.id == row_id)
|
|
|
|
if existing is None:
|
|
return False
|
|
|
|
if existing.kind in CHECK_KINDS:
|
|
Notice.delete_by_id(row_id)
|
|
return True
|
|
|
|
Notice.update(acknowledged_at=None, muted_at=None).where(
|
|
Notice.id == row_id
|
|
).execute()
|
|
|
|
self._notify()
|
|
return True
|
|
|
|
def unhide_all(self) -> None:
|
|
"""Show every acknowledged and muted row again."""
|
|
with self._lock:
|
|
for check in self.muted_checks():
|
|
Notice.delete_by_id(check["id"])
|
|
|
|
shown = (
|
|
Notice.update(acknowledged_at=None, muted_at=None)
|
|
.where(
|
|
Notice.acknowledged_at.is_null(False)
|
|
| Notice.muted_at.is_null(False)
|
|
)
|
|
.execute()
|
|
)
|
|
|
|
if shown:
|
|
self._notify()
|
|
|
|
def muted_checks(self) -> list[dict[str, Any]]:
|
|
"""Muted config and stream check rows, newest first."""
|
|
rows = (
|
|
Notice.select()
|
|
.where(Notice.kind.in_(list(CHECK_KINDS)))
|
|
.order_by(Notice.muted_at.desc())
|
|
)
|
|
return [{"id": row.id, "muted_at": row.muted_at} for row in rows]
|
|
|
|
def active(self, include_hidden: bool = False) -> list[dict[str, Any]]:
|
|
"""Notices most severe first, then most recent first.
|
|
|
|
Args:
|
|
include_hidden: Also return acknowledged and muted notices, for the
|
|
hidden list
|
|
"""
|
|
rows = []
|
|
|
|
for row in Notice.select():
|
|
definition = NOTICE_KINDS.get(row.kind)
|
|
|
|
if definition is None:
|
|
continue
|
|
|
|
hidden = row.acknowledged_at is not None or row.muted_at is not None
|
|
|
|
if hidden and not include_hidden:
|
|
continue
|
|
|
|
rows.append(
|
|
{
|
|
"id": row.id,
|
|
"kind": row.kind,
|
|
"severity": definition.severity.value,
|
|
"category": definition.category,
|
|
"scope": row.scope,
|
|
"params": row.params,
|
|
"link": definition.link_for(row.params),
|
|
"first_seen": row.first_seen,
|
|
"last_seen": row.last_seen,
|
|
"count": row.count,
|
|
"acknowledgeable": definition.counts_repeats,
|
|
"acknowledged_at": row.acknowledged_at,
|
|
"muted_at": row.muted_at,
|
|
}
|
|
)
|
|
|
|
rows.sort(
|
|
key=lambda n: (
|
|
SEVERITY_ORDER[NOTICE_KINDS[n["kind"]].severity],
|
|
-n["last_seen"],
|
|
)
|
|
)
|
|
return rows
|
|
|
|
def stats(self) -> list[dict[str, Any]]:
|
|
"""Lifetime counts per kind, for later analytics."""
|
|
return [
|
|
{
|
|
"kind": row.kind,
|
|
"occurrences": row.occurrences,
|
|
"acknowledgements": row.acknowledgements,
|
|
"mutes": row.mutes,
|
|
"first_seen": row.first_seen,
|
|
"last_seen": row.last_seen,
|
|
"reported_occurrences": row.reported_occurrences,
|
|
"reported_acknowledgements": row.reported_acknowledgements,
|
|
"reported_mutes": row.reported_mutes,
|
|
}
|
|
for row in NoticeStats.select()
|
|
if row.kind in NOTICE_KINDS
|
|
]
|
|
|
|
def _write_repeats(
|
|
self,
|
|
row: Notice,
|
|
kind: str,
|
|
count: int,
|
|
last_seen: float,
|
|
params: dict[str, Any],
|
|
) -> None:
|
|
# called with the lock held; held repeats from before an acknowledgement
|
|
# still count but leave the notice hidden
|
|
still_acknowledged = row.acknowledged_at is not None and (
|
|
last_seen <= cast(float, row.acknowledged_at)
|
|
)
|
|
|
|
Notice.update(
|
|
count=row.count + count,
|
|
last_seen=last_seen,
|
|
params=params,
|
|
acknowledged_at=row.acknowledged_at if still_acknowledged else None,
|
|
).where(Notice.id == row.id).execute()
|
|
self._bump_occurrences(kind, count, last_seen)
|
|
|
|
def _prune(self, kind: str, keep: int) -> None:
|
|
# called with the lock held
|
|
newest = (
|
|
Notice.select(Notice.id)
|
|
.where(Notice.kind == kind)
|
|
.order_by(Notice.first_seen.desc())
|
|
.limit(keep)
|
|
)
|
|
Notice.delete().where(
|
|
Notice.kind == kind,
|
|
Notice.id.not_in(newest),
|
|
).execute()
|
|
|
|
def _bump_occurrences(self, kind: str, count: int, now: float) -> None:
|
|
# called with the lock held
|
|
stats = NoticeStats.get_or_none(NoticeStats.kind == kind)
|
|
|
|
if stats is None:
|
|
NoticeStats.create(
|
|
kind=kind,
|
|
occurrences=count,
|
|
acknowledgements=0,
|
|
mutes=0,
|
|
first_seen=now,
|
|
last_seen=now,
|
|
)
|
|
else:
|
|
NoticeStats.update(
|
|
occurrences=NoticeStats.occurrences + count, last_seen=now
|
|
).where(NoticeStats.kind == kind).execute()
|