mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-16 11:42:47 +03:00
Notices and status bar improvements (#24459)
* 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
This commit is contained in:
+50
-22
@@ -14,17 +14,18 @@ router = APIRouter(tags=[Tags.notices])
|
||||
|
||||
|
||||
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
|
||||
def get_notices(request: Request, include_hidden: bool = False) -> JSONResponse:
|
||||
"""Get notices, most severe first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
|
||||
Returns:
|
||||
The notices
|
||||
"""
|
||||
return JSONResponse(
|
||||
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
|
||||
content=request.app.notice_registry.active(include_hidden=include_hidden)
|
||||
)
|
||||
|
||||
|
||||
@@ -34,37 +35,64 @@ def get_notice_stats(request: Request) -> JSONResponse:
|
||||
return JSONResponse(content=request.app.notice_registry.stats())
|
||||
|
||||
|
||||
@router.get(
|
||||
"/notices/dismissed_checks", dependencies=[Depends(require_role(["admin"]))]
|
||||
)
|
||||
def get_dismissed_checks(request: Request) -> JSONResponse:
|
||||
"""Get the dismissed config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.dismissed_checks())
|
||||
@router.get("/notices/muted_checks", dependencies=[Depends(require_role(["admin"]))])
|
||||
def get_muted_checks(request: Request) -> JSONResponse:
|
||||
"""Get the muted config and stream check rows, newest first."""
|
||||
return JSONResponse(content=request.app.notice_registry.muted_checks())
|
||||
|
||||
|
||||
@router.delete("/notices/dismissed", dependencies=[Depends(require_role(["admin"]))])
|
||||
def purge_dismissed(request: Request) -> JSONResponse:
|
||||
"""Delete every dismissed notice and check row so each can show again."""
|
||||
request.app.notice_registry.purge_dismissed()
|
||||
return JSONResponse(
|
||||
content={"success": True, "message": "Dismissed notices cleared"}
|
||||
)
|
||||
@router.delete("/notices/hidden", dependencies=[Depends(require_role(["admin"]))])
|
||||
def unhide_all_notices(request: Request) -> JSONResponse:
|
||||
"""Show every acknowledged and muted notice and check row again."""
|
||||
request.app.notice_registry.unhide_all()
|
||||
return JSONResponse(content={"success": True, "message": "Notices shown again"})
|
||||
|
||||
|
||||
# model notice ids contain a slash, so the id is a path parameter
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/dismiss",
|
||||
"/notices/{notice_id:path}/acknowledge",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row.
|
||||
def acknowledge_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice until it happens again.
|
||||
|
||||
It stays hidden if the same problem happens again.
|
||||
Config and stream check rows and the update notice never repeat, so they
|
||||
can only be muted.
|
||||
"""
|
||||
if not request.app.notice_registry.dismiss(notice_id):
|
||||
if not request.app.notice_registry.acknowledge(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice dismissed"})
|
||||
return JSONResponse(content={"success": True, "message": "Notice acknowledged"})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/notices/{notice_id:path}/mute",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def mute_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Hide a notice or a config or stream check row for good."""
|
||||
if not request.app.notice_registry.mute(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice muted"})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/notices/{notice_id:path}/hidden",
|
||||
dependencies=[Depends(require_role(["admin"]))],
|
||||
)
|
||||
def unhide_notice(request: Request, notice_id: str) -> JSONResponse:
|
||||
"""Show an acknowledged or muted notice or check row again."""
|
||||
if not request.app.notice_registry.unhide(notice_id):
|
||||
return JSONResponse(
|
||||
content={"success": False, "message": "Notice not found"},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
return JSONResponse(content={"success": True, "message": "Notice shown again"})
|
||||
|
||||
+8
-3
@@ -195,15 +195,20 @@ class Notice(Model):
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
count = IntegerField(default=1)
|
||||
dismissed_at = DateTimeField(null=True)
|
||||
# hidden until the next occurrence
|
||||
acknowledged_at = DateTimeField(null=True)
|
||||
# hidden for good
|
||||
muted_at = DateTimeField(null=True)
|
||||
|
||||
|
||||
class NoticeStats(Model):
|
||||
kind = CharField(null=False, primary_key=True, max_length=50)
|
||||
occurrences = IntegerField(default=0)
|
||||
dismissals = IntegerField(default=0)
|
||||
acknowledgements = IntegerField(default=0)
|
||||
mutes = IntegerField(default=0)
|
||||
first_seen = DateTimeField()
|
||||
last_seen = DateTimeField()
|
||||
# watermarks for a future analytics reporter; unused until then
|
||||
reported_occurrences = IntegerField(default=0)
|
||||
reported_dismissals = IntegerField(default=0)
|
||||
reported_acknowledgements = IntegerField(default=0)
|
||||
reported_mutes = IntegerField(default=0)
|
||||
|
||||
+110
-43
@@ -5,7 +5,7 @@ import threading
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from frigate.const import REPLAY_CAMERA_PREFIX
|
||||
from frigate.models import Notice, NoticeStats
|
||||
@@ -77,9 +77,8 @@ class NoticeRegistry:
|
||||
) -> None:
|
||||
"""Insert a notice or count another occurrence of it.
|
||||
|
||||
A dismissed notice stays dismissed when it is raised again, unless its
|
||||
kind sets reopen_at_count. A kind that should come back after a
|
||||
dismissal gives each episode its own scope.
|
||||
Another occurrence shows an acknowledged notice again. A muted notice
|
||||
stays hidden.
|
||||
"""
|
||||
definition = NOTICE_KINDS.get(kind)
|
||||
|
||||
@@ -112,7 +111,6 @@ class NoticeRegistry:
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=None,
|
||||
)
|
||||
self._bump_occurrences(kind, 1, now)
|
||||
|
||||
@@ -175,7 +173,7 @@ class NoticeRegistry:
|
||||
self._notify()
|
||||
|
||||
def resolve_camera(self, camera: str) -> None:
|
||||
"""Drop the notices and check dismissals of a camera being deleted."""
|
||||
"""Drop the notices and check mutes of a camera being deleted."""
|
||||
camera_kinds = [
|
||||
key
|
||||
for key, definition in NOTICE_KINDS.items()
|
||||
@@ -193,7 +191,7 @@ class NoticeRegistry:
|
||||
)
|
||||
|
||||
# a stream id names its camera first; a config id ends with camera.<name>
|
||||
for check in self.dismissed_checks():
|
||||
for check in self.muted_checks():
|
||||
check_id = check["id"]
|
||||
|
||||
if check_id.startswith(f"stream:{camera}:") or (
|
||||
@@ -205,26 +203,50 @@ class NoticeRegistry:
|
||||
if deleted:
|
||||
self._notify()
|
||||
|
||||
def purge_dismissed(self) -> int:
|
||||
"""Delete every dismissed row so each can show again. Returns how many."""
|
||||
with self._lock:
|
||||
return int(
|
||||
Notice.delete().where(Notice.dismissed_at.is_null(False)).execute()
|
||||
)
|
||||
def acknowledge(self, row_id: str) -> bool:
|
||||
"""Hide a notice until it happens again.
|
||||
|
||||
def dismiss(self, row_id: str) -> bool:
|
||||
"""Hide a notice or check row for good. Returns False for an unknown id."""
|
||||
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:
|
||||
# a check row gets a notice row only once it is dismissed
|
||||
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
|
||||
|
||||
now = datetime.now().timestamp()
|
||||
Notice.create(
|
||||
id=row_id,
|
||||
kind=kind,
|
||||
@@ -233,40 +255,78 @@ class NoticeRegistry:
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
count=1,
|
||||
dismissed_at=now,
|
||||
muted_at=now,
|
||||
)
|
||||
return True
|
||||
|
||||
if existing.dismissed_at is not None:
|
||||
if existing.muted_at is not None:
|
||||
return True
|
||||
|
||||
if existing.kind not in NOTICE_KINDS:
|
||||
return False
|
||||
|
||||
Notice.update(dismissed_at=datetime.now().timestamp()).where(
|
||||
Notice.update(acknowledged_at=None, muted_at=now).where(
|
||||
Notice.id == row_id
|
||||
).execute()
|
||||
NoticeStats.update(dismissals=NoticeStats.dismissals + 1).where(
|
||||
NoticeStats.update(mutes=NoticeStats.mutes + 1).where(
|
||||
NoticeStats.kind == existing.kind
|
||||
).execute()
|
||||
|
||||
self._notify()
|
||||
return True
|
||||
|
||||
def dismissed_checks(self) -> list[dict[str, Any]]:
|
||||
"""Dismissed config and stream check rows, newest first."""
|
||||
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.dismissed_at.desc())
|
||||
.order_by(Notice.muted_at.desc())
|
||||
)
|
||||
return [{"id": row.id, "dismissed_at": row.dismissed_at} for row in rows]
|
||||
return [{"id": row.id, "muted_at": row.muted_at} for row in rows]
|
||||
|
||||
def active(self, include_dismissed: bool = False) -> list[dict[str, Any]]:
|
||||
def active(self, include_hidden: bool = False) -> list[dict[str, Any]]:
|
||||
"""Notices most severe first, then most recent first.
|
||||
|
||||
Args:
|
||||
include_dismissed: Also return dismissed notices, for the history view
|
||||
include_hidden: Also return acknowledged and muted notices, for the
|
||||
hidden list
|
||||
"""
|
||||
rows = []
|
||||
|
||||
@@ -276,7 +336,9 @@ class NoticeRegistry:
|
||||
if definition is None:
|
||||
continue
|
||||
|
||||
if row.dismissed_at is not None and not include_dismissed:
|
||||
hidden = row.acknowledged_at is not None or row.muted_at is not None
|
||||
|
||||
if hidden and not include_hidden:
|
||||
continue
|
||||
|
||||
rows.append(
|
||||
@@ -291,7 +353,9 @@ class NoticeRegistry:
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"count": row.count,
|
||||
"dismissed_at": row.dismissed_at,
|
||||
"acknowledgeable": definition.counts_repeats,
|
||||
"acknowledged_at": row.acknowledged_at,
|
||||
"muted_at": row.muted_at,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -309,11 +373,13 @@ class NoticeRegistry:
|
||||
{
|
||||
"kind": row.kind,
|
||||
"occurrences": row.occurrences,
|
||||
"dismissals": row.dismissals,
|
||||
"acknowledgements": row.acknowledgements,
|
||||
"mutes": row.mutes,
|
||||
"first_seen": row.first_seen,
|
||||
"last_seen": row.last_seen,
|
||||
"reported_occurrences": row.reported_occurrences,
|
||||
"reported_dismissals": row.reported_dismissals,
|
||||
"reported_acknowledgements": row.reported_acknowledgements,
|
||||
"reported_mutes": row.reported_mutes,
|
||||
}
|
||||
for row in NoticeStats.select()
|
||||
if row.kind in NOTICE_KINDS
|
||||
@@ -327,18 +393,18 @@ class NoticeRegistry:
|
||||
last_seen: float,
|
||||
params: dict[str, Any],
|
||||
) -> None:
|
||||
# called with the lock held
|
||||
fields: dict[str, Any] = {
|
||||
"count": row.count + count,
|
||||
"last_seen": last_seen,
|
||||
"params": params,
|
||||
}
|
||||
reopen_at = NOTICE_KINDS[kind].reopen_at_count
|
||||
# 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)
|
||||
)
|
||||
|
||||
if reopen_at is not None and row.count < reopen_at <= row.count + count:
|
||||
fields["dismissed_at"] = None
|
||||
|
||||
Notice.update(**fields).where(Notice.id == row.id).execute()
|
||||
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:
|
||||
@@ -362,7 +428,8 @@ class NoticeRegistry:
|
||||
NoticeStats.create(
|
||||
kind=kind,
|
||||
occurrences=count,
|
||||
dismissals=0,
|
||||
acknowledgements=0,
|
||||
mutes=0,
|
||||
first_seen=now,
|
||||
last_seen=now,
|
||||
)
|
||||
|
||||
@@ -28,9 +28,9 @@ class NoticeKind:
|
||||
category: camera, detector, model, or system; a camera scope is a
|
||||
camera name, and the UI shows it
|
||||
link: app route or absolute URL for the row, filled in from params
|
||||
counts_repeats: whether raising an existing notice counts another occurrence
|
||||
counts_repeats: whether raising an existing notice counts another
|
||||
occurrence, which also shows an acknowledged notice again
|
||||
batch_repeats: whether repeats wait in memory for the next flush
|
||||
reopen_at_count: count at which a dismissed notice shows again
|
||||
keep_latest: rows of this kind to keep; a new row drops the oldest
|
||||
reportable: whether a future analytics reporter may send this kind's counts
|
||||
"""
|
||||
@@ -41,7 +41,6 @@ class NoticeKind:
|
||||
link: str | None = None
|
||||
counts_repeats: bool = True
|
||||
batch_repeats: bool = False
|
||||
reopen_at_count: int | None = None
|
||||
keep_latest: int | None = None
|
||||
reportable: bool = True
|
||||
|
||||
@@ -67,6 +66,12 @@ _KINDS = (
|
||||
"camera",
|
||||
link="/system#cameras",
|
||||
),
|
||||
NoticeKind(
|
||||
"ffmpeg_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
|
||||
),
|
||||
NoticeKind(
|
||||
"detect_high_cpu", NoticeSeverity.warning, "camera", link="/system#cameras"
|
||||
),
|
||||
NoticeKind("shm_too_low", NoticeSeverity.warning, "system", link="/system#storage"),
|
||||
# one row per user per burst; the login log lines carry the address
|
||||
NoticeKind(
|
||||
@@ -75,10 +80,9 @@ _KINDS = (
|
||||
"system",
|
||||
link="/logs",
|
||||
batch_repeats=True,
|
||||
reopen_at_count=5,
|
||||
keep_latest=100,
|
||||
),
|
||||
# one row per release, so a dismissal lasts until the next release
|
||||
# one row per release, so muting it lasts until the next release
|
||||
NoticeKind(
|
||||
"update_available",
|
||||
NoticeSeverity.info,
|
||||
@@ -92,7 +96,7 @@ _KINDS = (
|
||||
NOTICE_KINDS: dict[str, NoticeKind] = {kind.key: kind for kind in _KINDS}
|
||||
|
||||
# the Health tab builds config and stream check rows in the browser, so a notice
|
||||
# row of these kinds only records a dismissal; its other fields are placeholders
|
||||
# row of these kinds only records a mute; its other fields are placeholders
|
||||
CHECK_KINDS = frozenset({"config", "stream"})
|
||||
|
||||
|
||||
|
||||
+61
-15
@@ -29,38 +29,59 @@ VERSION_REFRESH_S = 24 * 60 * 60
|
||||
# a camera skipping at least this percent of its frames is falling behind
|
||||
SKIPPED_DETECTIONS_PCT = 5
|
||||
|
||||
# for at least this long before it becomes a notice
|
||||
SKIPPED_DETECTIONS_HOLD_S = 60
|
||||
# lifetime CPU averages at or above these are high; they match
|
||||
# CameraFfmpegThreshold.error and CameraDetectThreshold.error in the UI
|
||||
FFMPEG_HIGH_CPU_PCT = 20
|
||||
DETECT_HIGH_CPU_PCT = 40
|
||||
|
||||
# a camera stays over a threshold this long before it becomes a notice
|
||||
EPISODE_HOLD_S = 60
|
||||
|
||||
# detectors warm up after a start; the status bar waits this long too
|
||||
STARTUP_GRACE_S = 120
|
||||
|
||||
|
||||
class SkippedDetectionsTracker:
|
||||
"""Finds cameras whose skipped share stays high long enough for a notice."""
|
||||
def cpu_average(cpu_usages: dict[str, Any], pid: int | None) -> float | None:
|
||||
"""A process's CPU use averaged over its lifetime, or None if unknown."""
|
||||
usage = cpu_usages.get(str(pid)) if pid else None
|
||||
|
||||
def __init__(self) -> None:
|
||||
try:
|
||||
return float(usage["cpu_average"]) if usage else None
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
class EpisodeTracker:
|
||||
"""Finds cameras whose value stays over a threshold long enough for a notice."""
|
||||
|
||||
def __init__(self, threshold: float) -> None:
|
||||
self._threshold = threshold
|
||||
self._since: dict[str, float] = {}
|
||||
self._raised: set[str] = set()
|
||||
|
||||
def update(self, cameras: dict[str, dict[str, Any]], now: float) -> list[str]:
|
||||
"""Return the cameras whose episode qualified on this sample."""
|
||||
def update(self, values: dict[str, float | None], now: float) -> list[str]:
|
||||
"""Return the cameras whose episode qualified on this sample.
|
||||
|
||||
Args:
|
||||
values: Each camera's current value, or None when it has none
|
||||
now: Sample time in seconds
|
||||
"""
|
||||
qualified: list[str] = []
|
||||
|
||||
# a removed camera that comes back starts a new episode
|
||||
for camera in self._since.keys() - cameras.keys():
|
||||
for camera in self._since.keys() - values.keys():
|
||||
self._since.pop(camera)
|
||||
self._raised.discard(camera)
|
||||
|
||||
for camera, camera_stats in cameras.items():
|
||||
if camera_stats["skipped_pct"] < SKIPPED_DETECTIONS_PCT:
|
||||
for camera, value in values.items():
|
||||
if value is None or value < self._threshold:
|
||||
self._since.pop(camera, None)
|
||||
self._raised.discard(camera)
|
||||
continue
|
||||
|
||||
since = self._since.setdefault(camera, now)
|
||||
|
||||
if camera not in self._raised and now - since >= SKIPPED_DETECTIONS_HOLD_S:
|
||||
if camera not in self._raised and now - since >= EPISODE_HOLD_S:
|
||||
self._raised.add(camera)
|
||||
qualified.append(camera)
|
||||
|
||||
@@ -80,7 +101,9 @@ class StatsEmitter(threading.Thread):
|
||||
self.stop_event = stop_event
|
||||
self.hardware_stats = HardwareStats(config)
|
||||
self.stats_history: list[dict[str, Any]] = []
|
||||
self.skipped_detections = SkippedDetectionsTracker()
|
||||
self.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
self.ffmpeg_cpu = EpisodeTracker(FFMPEG_HIGH_CPU_PCT)
|
||||
self.detect_cpu = EpisodeTracker(DETECT_HIGH_CPU_PCT)
|
||||
|
||||
# the shm notice's params as last sent, so only a change is written
|
||||
self._shm_checked = False
|
||||
@@ -227,15 +250,38 @@ class StatsEmitter(threading.Thread):
|
||||
|
||||
def _update_notices(self, stats: dict[str, Any], now: float) -> None:
|
||||
"""Update notices based on current stats or time."""
|
||||
# skipped detections
|
||||
cameras = stats["cameras"]
|
||||
|
||||
# absent when CPU collection timed out or failed on this tick
|
||||
cpu_usages = stats.get("cpu_usages", {})
|
||||
|
||||
if stats["service"]["uptime"] >= STARTUP_GRACE_S:
|
||||
for camera in self.skipped_detections.update(stats["cameras"], now):
|
||||
# skipped detections
|
||||
skipped = {
|
||||
camera: camera_stats["skipped_pct"]
|
||||
for camera, camera_stats in cameras.items()
|
||||
}
|
||||
|
||||
for camera in self.skipped_detections.update(skipped, now):
|
||||
raise_notice(
|
||||
"skipped_detections",
|
||||
scope=camera,
|
||||
params={"pct": stats["cameras"][camera]["skipped_pct"]},
|
||||
params={"pct": skipped[camera]},
|
||||
)
|
||||
|
||||
# high ffmpeg and detect CPU
|
||||
for tracker, kind, pid_key in (
|
||||
(self.ffmpeg_cpu, "ffmpeg_high_cpu", "ffmpeg_pid"),
|
||||
(self.detect_cpu, "detect_high_cpu", "pid"),
|
||||
):
|
||||
averages = {
|
||||
camera: cpu_average(cpu_usages, camera_stats.get(pid_key))
|
||||
for camera, camera_stats in cameras.items()
|
||||
}
|
||||
|
||||
for camera in tracker.update(averages, now):
|
||||
raise_notice(kind, scope=camera, params={"cpu": averages[camera]})
|
||||
|
||||
# shm too small for the cameras
|
||||
self._update_shm_notice(stats["service"]["storage"]["/dev/shm"])
|
||||
|
||||
|
||||
@@ -53,92 +53,108 @@ class TestHttpNotices(BaseTestHttp):
|
||||
self.assertEqual(response.json()[0]["kind"], "detector_stuck")
|
||||
self.assertEqual(response.json()[0]["occurrences"], 1)
|
||||
|
||||
def test_dismiss_hides_and_history_keeps_it(self):
|
||||
def test_acknowledge_hides_and_the_hidden_list_keeps_it(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/detector_stuck:ov/dismiss")
|
||||
response = client.post("/notices/detector_stuck:ov/acknowledge")
|
||||
listed = client.get("/notices").json()
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
hidden = client.get("/notices", params={"include_hidden": True}).json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(listed, [])
|
||||
self.assertEqual([n["id"] for n in history], ["detector_stuck:ov"])
|
||||
self.assertIsNotNone(history[0]["dismissed_at"])
|
||||
self.assertEqual([n["id"] for n in hidden], ["detector_stuck:ov"])
|
||||
self.assertIsNotNone(hidden[0]["acknowledged_at"])
|
||||
|
||||
def test_dismiss_an_id_with_a_slash(self):
|
||||
def test_acknowledging_a_check_is_404(self):
|
||||
with self.client() as client:
|
||||
response = client.post(f"/notices/{CONFIG_CHECK}/acknowledge")
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_mute_an_id_with_a_slash(self):
|
||||
self.registry.raise_notice(
|
||||
"model_download_failed", scope="yolo/model.onnx", params={}
|
||||
)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.post(
|
||||
"/notices/model_download_failed:yolo/model.onnx/dismiss"
|
||||
"/notices/model_download_failed:yolo/model.onnx/mute"
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
def test_dismiss_unknown_is_404(self):
|
||||
def test_unknown_ids_are_404(self):
|
||||
with self.client() as client:
|
||||
response = client.post("/notices/nope/dismiss")
|
||||
responses = [
|
||||
client.post("/notices/nope/acknowledge"),
|
||||
client.post("/notices/nope/mute"),
|
||||
client.delete("/notices/nope/hidden"),
|
||||
]
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual([r.status_code for r in responses], [404, 404, 404])
|
||||
|
||||
def test_requires_admin(self):
|
||||
viewer = {"remote-user": "viewer", "remote-role": "viewer"}
|
||||
|
||||
with TestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/notices", headers={"remote-user": "viewer", "remote-role": "viewer"}
|
||||
)
|
||||
responses = [
|
||||
client.get("/notices", headers=viewer),
|
||||
client.get("/notices/muted_checks", headers=viewer),
|
||||
client.post("/notices/detector_stuck:ov/acknowledge", headers=viewer),
|
||||
client.post("/notices/detector_stuck:ov/mute", headers=viewer),
|
||||
client.delete("/notices/detector_stuck:ov/hidden", headers=viewer),
|
||||
client.delete("/notices/hidden", headers=viewer),
|
||||
]
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.assertEqual([r.status_code for r in responses], [403] * 6)
|
||||
|
||||
def test_dismissed_checks_are_listed_once(self):
|
||||
def test_muted_checks_are_listed_once(self):
|
||||
with self.client() as client:
|
||||
first = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
|
||||
again = client.post(f"/notices/{CONFIG_CHECK}/dismiss")
|
||||
listed = client.get("/notices/dismissed_checks").json()
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
first = client.post(f"/notices/{CONFIG_CHECK}/mute")
|
||||
again = client.post(f"/notices/{CONFIG_CHECK}/mute")
|
||||
listed = client.get("/notices/muted_checks").json()
|
||||
hidden = client.get("/notices", params={"include_hidden": True}).json()
|
||||
|
||||
self.assertEqual(first.status_code, 200)
|
||||
self.assertEqual(again.status_code, 200)
|
||||
self.assertEqual([check["id"] for check in listed], [CONFIG_CHECK])
|
||||
self.assertIsNotNone(listed[0]["dismissed_at"])
|
||||
self.assertIsNotNone(listed[0]["muted_at"])
|
||||
# the Health tab builds the row itself, so it never comes back as a notice
|
||||
self.assertEqual(history, [])
|
||||
self.assertEqual(hidden, [])
|
||||
|
||||
def test_dismissed_checks_require_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.get(
|
||||
"/notices/dismissed_checks",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_purge_dismissed_clears_notices_and_checks(self):
|
||||
def test_unhide_one_row(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.dismiss(CONFIG_CHECK)
|
||||
self.registry.mute("detector_stuck:ov")
|
||||
self.registry.mute(CONFIG_CHECK)
|
||||
|
||||
with self.client() as client:
|
||||
response = client.delete("/notices/dismissed")
|
||||
history = client.get("/notices", params={"include_dismissed": True}).json()
|
||||
checks = client.get("/notices/dismissed_checks").json()
|
||||
notice = client.delete("/notices/detector_stuck:ov/hidden")
|
||||
check = client.delete(f"/notices/{CONFIG_CHECK}/hidden")
|
||||
listed = client.get("/notices").json()
|
||||
checks = client.get("/notices/muted_checks").json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(history, [])
|
||||
self.assertEqual([notice.status_code, check.status_code], [200, 200])
|
||||
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
|
||||
self.assertEqual(checks, [])
|
||||
|
||||
def test_purge_dismissed_requires_admin(self):
|
||||
with TestClient(self.app) as client:
|
||||
response = client.delete(
|
||||
"/notices/dismissed",
|
||||
headers={"remote-user": "viewer", "remote-role": "viewer"},
|
||||
)
|
||||
def test_unhide_all_shows_notices_and_drops_check_mutes(self):
|
||||
self.registry.raise_notice(
|
||||
"detector_stuck", scope="ov", params={"detector": "ov"}
|
||||
)
|
||||
self.registry.acknowledge("detector_stuck:ov")
|
||||
self.registry.mute(CONFIG_CHECK)
|
||||
|
||||
self.assertEqual(response.status_code, 403)
|
||||
with self.client() as client:
|
||||
response = client.delete("/notices/hidden")
|
||||
listed = client.get("/notices").json()
|
||||
checks = client.get("/notices/muted_checks").json()
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual([n["id"] for n in listed], ["detector_stuck:ov"])
|
||||
self.assertEqual(checks, [])
|
||||
|
||||
@@ -16,9 +16,7 @@ from frigate.test.const import TEST_DB, TEST_DB_CLEANUPS
|
||||
|
||||
# kinds that switch on the lifecycle knobs, so the tests do not depend on the
|
||||
# values the real catalog picks
|
||||
BATCHED = NoticeKind(
|
||||
"batched", NoticeSeverity.warning, "system", batch_repeats=True, reopen_at_count=3
|
||||
)
|
||||
BATCHED = NoticeKind("batched", NoticeSeverity.warning, "system", batch_repeats=True)
|
||||
PRUNED = NoticeKind("pruned", NoticeSeverity.info, "system", keep_latest=2)
|
||||
TEST_KINDS = {kind.key: kind for kind in (BATCHED, PRUNED)}
|
||||
|
||||
@@ -77,6 +75,11 @@ class RegistryTestCase(unittest.TestCase):
|
||||
mock_datetime.now.return_value.timestamp.return_value = timestamp
|
||||
self.registry.raise_notice(kind, **kwargs)
|
||||
|
||||
def _acknowledge_at(self, timestamp: float, row_id: str) -> None:
|
||||
with patch("frigate.notices.registry.datetime") as mock_datetime:
|
||||
mock_datetime.now.return_value.timestamp.return_value = timestamp
|
||||
self.registry.acknowledge(row_id)
|
||||
|
||||
|
||||
class TestNoticeRegistry(RegistryTestCase):
|
||||
def test_raise_inserts_and_counts_one_occurrence(self):
|
||||
@@ -122,34 +125,98 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_dismissal_survives_a_re_raise(self):
|
||||
def test_acknowledged_notice_returns_on_a_re_raise(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
|
||||
notice = self.registry.active()[0]
|
||||
self.assertEqual(notice["count"], 2)
|
||||
self.assertIsNone(notice["acknowledged_at"])
|
||||
|
||||
def test_muted_notice_stays_hidden_on_a_re_raise(self):
|
||||
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
||||
self.assertTrue(self.registry.dismiss("skipped_detections:front_door"))
|
||||
self.assertTrue(self.registry.mute("skipped_detections:front_door"))
|
||||
|
||||
self.registry.raise_notice("skipped_detections", scope="front_door", params={})
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
history = self.registry.active(include_dismissed=True)
|
||||
self.assertEqual(history[0]["count"], 2)
|
||||
self.assertIsNotNone(history[0]["dismissed_at"])
|
||||
hidden = self.registry.active(include_hidden=True)
|
||||
self.assertEqual(hidden[0]["count"], 2)
|
||||
self.assertIsNotNone(hidden[0]["muted_at"])
|
||||
|
||||
def test_dismiss_counts_once(self):
|
||||
def test_muting_an_acknowledged_notice_replaces_the_acknowledgement(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.acknowledge("detector_stuck:ov")
|
||||
|
||||
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.dismiss("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.mute("detector_stuck:ov"))
|
||||
|
||||
self.assertEqual(self.registry.stats()[0]["dismissals"], 1)
|
||||
notice = self.registry.active(include_hidden=True)[0]
|
||||
self.assertIsNone(notice["acknowledged_at"])
|
||||
self.assertIsNotNone(notice["muted_at"])
|
||||
|
||||
def test_dismiss_unknown_id(self):
|
||||
self.assertFalse(self.registry.dismiss("nope"))
|
||||
|
||||
def test_dismissed_hidden_unless_requested(self):
|
||||
def test_acknowledging_a_muted_notice_keeps_it_muted(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.mute("detector_stuck:ov")
|
||||
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
|
||||
notice = self.registry.active(include_hidden=True)[0]
|
||||
self.assertIsNone(notice["acknowledged_at"])
|
||||
self.assertIsNotNone(notice["muted_at"])
|
||||
|
||||
def test_acknowledge_and_mute_each_count_once(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.raise_notice("shm_too_low", params={})
|
||||
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.acknowledge("detector_stuck:ov"))
|
||||
self.assertTrue(self.registry.mute("shm_too_low"))
|
||||
self.assertTrue(self.registry.mute("shm_too_low"))
|
||||
|
||||
stats = {s["kind"]: s for s in self.registry.stats()}
|
||||
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
|
||||
self.assertEqual(stats["detector_stuck"]["mutes"], 0)
|
||||
self.assertEqual(stats["shm_too_low"]["mutes"], 1)
|
||||
|
||||
def test_unknown_ids_are_rejected(self):
|
||||
self.assertFalse(self.registry.acknowledge("nope"))
|
||||
self.assertFalse(self.registry.mute("nope"))
|
||||
self.assertFalse(self.registry.unhide("nope"))
|
||||
|
||||
def test_kinds_that_never_repeat_cannot_be_acknowledged(self):
|
||||
self.registry.raise_notice(
|
||||
"update_available", scope="0.19.1", params={"version": "0.19.1"}
|
||||
)
|
||||
|
||||
self.assertFalse(self.registry.acknowledge("update_available:0.19.1"))
|
||||
self.assertFalse(
|
||||
self.registry.acknowledge("config:detect:fps-greater-than-five:global")
|
||||
)
|
||||
self.assertEqual(self.registry.active()[0]["acknowledgeable"], False)
|
||||
|
||||
def test_hidden_notices_listed_only_when_requested(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.acknowledge("detector_stuck:ov")
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(len(self.registry.active(include_dismissed=True)), 1)
|
||||
self.assertEqual(len(self.registry.active(include_hidden=True)), 1)
|
||||
|
||||
def test_unhide_shows_a_notice_and_drops_a_check_mute(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.mute("detector_stuck:ov")
|
||||
self.registry.mute("config:detect:fps-greater-than-five:global")
|
||||
|
||||
self.assertTrue(self.registry.unhide("detector_stuck:ov"))
|
||||
self.assertTrue(
|
||||
self.registry.unhide("config:detect:fps-greater-than-five:global")
|
||||
)
|
||||
|
||||
notice = self.registry.active()[0]
|
||||
self.assertIsNone(notice["muted_at"])
|
||||
self.assertEqual(self.registry.muted_checks(), [])
|
||||
|
||||
def test_resolve_deletes_and_keeps_stats(self):
|
||||
self.registry.raise_notice(
|
||||
@@ -160,7 +227,7 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
self.registry.resolve("model_download_failed", "yolo/model.onnx")
|
||||
self.registry.resolve("model_download_failed", "yolo/model.onnx")
|
||||
|
||||
self.assertEqual(self.registry.active(include_dismissed=True), [])
|
||||
self.assertEqual(self.registry.active(include_hidden=True), [])
|
||||
self.assertEqual(self.registry.stats()[0]["occurrences"], 1)
|
||||
self.listener.assert_called_once()
|
||||
|
||||
@@ -237,45 +304,47 @@ class TestNoticeRegistry(RegistryTestCase):
|
||||
ids, ["model_download_failed:front_door", "skipped_detections:garage"]
|
||||
)
|
||||
|
||||
def test_resolve_camera_drops_that_cameras_check_dismissals(self):
|
||||
def test_resolve_camera_drops_that_cameras_check_mutes(self):
|
||||
for check_id in (
|
||||
"stream:front_door:0:probe",
|
||||
"config:detect:fps-greater-than-five:camera.front_door",
|
||||
"config:detect:fps-greater-than-five:global",
|
||||
"stream:garage:0:probe",
|
||||
):
|
||||
self.assertTrue(self.registry.dismiss(check_id))
|
||||
self.assertTrue(self.registry.mute(check_id))
|
||||
|
||||
self.registry.resolve_camera("front_door")
|
||||
|
||||
ids = sorted(check["id"] for check in self.registry.dismissed_checks())
|
||||
ids = sorted(check["id"] for check in self.registry.muted_checks())
|
||||
self.assertEqual(
|
||||
ids,
|
||||
["config:detect:fps-greater-than-five:global", "stream:garage:0:probe"],
|
||||
)
|
||||
|
||||
def test_camera_named_global_keeps_global_check_dismissals(self):
|
||||
self.registry.dismiss("config:detect:fps-greater-than-five:global")
|
||||
self.registry.dismiss("config:detect:fps-greater-than-five:camera.global")
|
||||
def test_camera_named_global_keeps_global_check_mutes(self):
|
||||
self.registry.mute("config:detect:fps-greater-than-five:global")
|
||||
self.registry.mute("config:detect:fps-greater-than-five:camera.global")
|
||||
|
||||
self.registry.resolve_camera("global")
|
||||
|
||||
ids = [check["id"] for check in self.registry.dismissed_checks()]
|
||||
ids = [check["id"] for check in self.registry.muted_checks()]
|
||||
self.assertEqual(ids, ["config:detect:fps-greater-than-five:global"])
|
||||
|
||||
def test_purge_dismissed_keeps_active_notices_and_counts(self):
|
||||
def test_unhide_all_shows_every_notice_and_keeps_counts(self):
|
||||
self.registry.raise_notice("detector_stuck", scope="ov", params={})
|
||||
self.registry.raise_notice("skipped_detections", scope="garage", params={})
|
||||
self.registry.dismiss("detector_stuck:ov")
|
||||
self.registry.dismiss("config:detect:fps-greater-than-five:camera.garage")
|
||||
self.registry.acknowledge("detector_stuck:ov")
|
||||
self.registry.mute("skipped_detections:garage")
|
||||
self.registry.mute("config:detect:fps-greater-than-five:camera.garage")
|
||||
|
||||
self.assertEqual(self.registry.purge_dismissed(), 2)
|
||||
self.registry.unhide_all()
|
||||
|
||||
ids = [n["id"] for n in self.registry.active(include_dismissed=True)]
|
||||
self.assertEqual(ids, ["skipped_detections:garage"])
|
||||
self.assertEqual(self.registry.dismissed_checks(), [])
|
||||
dismissals = {s["kind"]: s["dismissals"] for s in self.registry.stats()}
|
||||
self.assertEqual(dismissals["detector_stuck"], 1)
|
||||
ids = sorted(n["id"] for n in self.registry.active())
|
||||
self.assertEqual(ids, ["detector_stuck:ov", "skipped_detections:garage"])
|
||||
self.assertEqual(self.registry.muted_checks(), [])
|
||||
stats = {s["kind"]: s for s in self.registry.stats()}
|
||||
self.assertEqual(stats["detector_stuck"]["acknowledgements"], 1)
|
||||
self.assertEqual(stats["skipped_detections"]["mutes"], 1)
|
||||
|
||||
|
||||
class TestApply(RegistryTestCase):
|
||||
@@ -349,7 +418,7 @@ class TestLifecycleKnobs(RegistryTestCase):
|
||||
|
||||
self.registry.flush()
|
||||
|
||||
self.assertEqual(self.registry.active(include_dismissed=True), [])
|
||||
self.assertEqual(self.registry.active(include_hidden=True), [])
|
||||
self.listener.assert_not_called()
|
||||
|
||||
def test_a_new_row_starts_without_stale_repeats(self):
|
||||
@@ -362,32 +431,26 @@ class TestLifecycleKnobs(RegistryTestCase):
|
||||
|
||||
self.assertEqual(self.registry.active()[0]["count"], 1)
|
||||
|
||||
def test_a_dismissed_notice_reopens_at_the_count(self):
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.dismiss("batched")
|
||||
def test_an_acknowledged_notice_returns_when_a_later_repeat_flushes(self):
|
||||
self._raise_at(1000.0, "batched")
|
||||
self._acknowledge_at(1010.0, "batched")
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
self._raise_at(1020.0, "batched")
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
|
||||
notice = self.registry.active()[0]
|
||||
self.assertEqual(notice["count"], BATCHED.reopen_at_count)
|
||||
self.assertIsNone(notice["dismissed_at"])
|
||||
self.assertEqual(self.registry.active()[0]["count"], 2)
|
||||
|
||||
def test_a_notice_dismissed_past_the_count_stays_dismissed(self):
|
||||
for _ in range(3):
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
self.registry.dismiss("batched")
|
||||
def test_repeats_held_from_before_an_acknowledgement_stay_hidden(self):
|
||||
self._raise_at(1000.0, "batched")
|
||||
self._raise_at(1005.0, "batched")
|
||||
self._acknowledge_at(1010.0, "batched")
|
||||
|
||||
self.registry.raise_notice("batched")
|
||||
self.registry.flush()
|
||||
|
||||
self.assertEqual(self.registry.active(), [])
|
||||
self.assertEqual(self.registry.active(include_dismissed=True)[0]["count"], 4)
|
||||
self.assertEqual(self.registry.active(include_hidden=True)[0]["count"], 2)
|
||||
|
||||
def test_keep_latest_drops_the_oldest_rows(self):
|
||||
for index, scope in enumerate(("a", "b", "c")):
|
||||
@@ -410,14 +473,13 @@ class TestCatalogLifecycles(RegistryTestCase):
|
||||
ids = [n["id"] for n in self.registry.active()]
|
||||
self.assertEqual(ids, ["update_available:0.19.1"])
|
||||
|
||||
def test_a_dismissed_failed_login_burst_reopens_at_five_attempts(self):
|
||||
def test_an_acknowledged_failed_login_burst_returns_on_the_next_attempt(self):
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.dismiss("failed_login:1000")
|
||||
self.registry.acknowledge("failed_login:1000")
|
||||
|
||||
for _ in range(4):
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.raise_notice("failed_login", scope="1000")
|
||||
self.registry.flush()
|
||||
|
||||
burst = self.registry.active()[0]
|
||||
self.assertEqual(burst["count"], 5)
|
||||
self.assertIsNone(burst["dismissed_at"])
|
||||
self.assertEqual(burst["count"], 2)
|
||||
self.assertIsNone(burst["acknowledged_at"])
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
"""Tests for the skipped detection rate and the notice it can raise."""
|
||||
"""Tests for the per-camera episode notices: skipped detections and high CPU."""
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import call, patch
|
||||
|
||||
from frigate.stats import emitter
|
||||
from frigate.stats.emitter import SkippedDetectionsTracker
|
||||
from frigate.stats.emitter import (
|
||||
SKIPPED_DETECTIONS_PCT,
|
||||
EpisodeTracker,
|
||||
cpu_average,
|
||||
)
|
||||
from frigate.stats.util import skipped_percent
|
||||
|
||||
|
||||
@@ -19,18 +23,18 @@ class TestSkippedPercent(unittest.TestCase):
|
||||
self.assertEqual(skipped_percent(2.0, 5.0, False), 0.0)
|
||||
|
||||
|
||||
class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
class TestEpisodeTracker(unittest.TestCase):
|
||||
def _cameras(self, pct: float) -> dict:
|
||||
return {"front_door": {"skipped_pct": pct}}
|
||||
return {"front_door": pct}
|
||||
|
||||
def test_below_threshold_never_qualifies(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
|
||||
for tick in range(10):
|
||||
self.assertEqual(tracker.update(self._cameras(4.9), tick * 15.0), [])
|
||||
|
||||
def test_qualifies_once_after_the_hold(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
|
||||
results = [
|
||||
tracker.update(self._cameras(10.0), tick * 15.0) for tick in range(8)
|
||||
@@ -42,7 +46,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
self.assertEqual(results[5:], [[], [], []])
|
||||
|
||||
def test_a_dip_restarts_the_hold(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
|
||||
for now, pct in ((0.0, 10.0), (15.0, 10.0), (30.0, 10.0), (45.0, 2.0)):
|
||||
self.assertEqual(tracker.update(self._cameras(pct), now), [])
|
||||
@@ -51,7 +55,7 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 120.0), ["front_door"])
|
||||
|
||||
def test_recovery_then_relapse_is_a_new_episode(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), ["front_door"])
|
||||
|
||||
@@ -61,17 +65,15 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 150.0), ["front_door"])
|
||||
|
||||
def test_cameras_are_tracked_separately(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker.update({"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 0.0}}, 0.0)
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update({"a": 10.0, "b": 0.0}, 0.0)
|
||||
|
||||
qualified = tracker.update(
|
||||
{"a": {"skipped_pct": 10.0}, "b": {"skipped_pct": 10.0}}, 60.0
|
||||
)
|
||||
qualified = tracker.update({"a": 10.0, "b": 10.0}, 60.0)
|
||||
|
||||
self.assertEqual(qualified, ["a"])
|
||||
|
||||
def test_a_removed_camera_starts_a_new_episode(self):
|
||||
tracker = SkippedDetectionsTracker()
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
tracker.update({}, 15.0)
|
||||
tracker.update(self._cameras(10.0), 30.0)
|
||||
@@ -79,6 +81,25 @@ class TestSkippedDetectionsTracker(unittest.TestCase):
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 90.0), ["front_door"])
|
||||
|
||||
def test_a_missing_value_ends_the_episode(self):
|
||||
tracker = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
tracker.update(self._cameras(10.0), 0.0)
|
||||
tracker.update({"front_door": None}, 30.0)
|
||||
|
||||
self.assertEqual(tracker.update(self._cameras(10.0), 60.0), [])
|
||||
|
||||
|
||||
class TestCpuAverage(unittest.TestCase):
|
||||
def test_reads_the_lifetime_average(self):
|
||||
usages = {"42": {"cpu": "80.0", "cpu_average": "25.0"}}
|
||||
|
||||
self.assertEqual(cpu_average(usages, 42), 25.0)
|
||||
|
||||
def test_unknown_process_has_no_average(self):
|
||||
self.assertIsNone(cpu_average({}, 42))
|
||||
self.assertIsNone(cpu_average({"42": {"cpu_average": "25.0"}}, None))
|
||||
self.assertIsNone(cpu_average({"42": {"cpu_average": ""}}, 42))
|
||||
|
||||
|
||||
class TestEmitterNotices(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -94,15 +115,25 @@ class TestEmitterNotices(unittest.TestCase):
|
||||
|
||||
def _emitter(self) -> emitter.StatsEmitter:
|
||||
stats_emitter = emitter.StatsEmitter.__new__(emitter.StatsEmitter)
|
||||
stats_emitter.skipped_detections = SkippedDetectionsTracker()
|
||||
stats_emitter.skipped_detections = EpisodeTracker(SKIPPED_DETECTIONS_PCT)
|
||||
stats_emitter.ffmpeg_cpu = EpisodeTracker(emitter.FFMPEG_HIGH_CPU_PCT)
|
||||
stats_emitter.detect_cpu = EpisodeTracker(emitter.DETECT_HIGH_CPU_PCT)
|
||||
stats_emitter._shm_checked = False
|
||||
stats_emitter._shm_params = None
|
||||
return stats_emitter
|
||||
|
||||
def _stats(self, uptime: int, pct: float) -> dict:
|
||||
def _stats(
|
||||
self, uptime: int, pct: float, ffmpeg_cpu: str = "5.0", detect_cpu: str = "5.0"
|
||||
) -> dict:
|
||||
return {
|
||||
"service": {"uptime": uptime, "storage": {"/dev/shm": {}}},
|
||||
"cameras": {"front_door": {"skipped_pct": pct}},
|
||||
"cameras": {
|
||||
"front_door": {"skipped_pct": pct, "ffmpeg_pid": 10, "pid": 11}
|
||||
},
|
||||
"cpu_usages": {
|
||||
"10": {"cpu_average": ffmpeg_cpu},
|
||||
"11": {"cpu_average": detect_cpu},
|
||||
},
|
||||
}
|
||||
|
||||
def test_qualified_camera_raises_a_notice(self):
|
||||
@@ -115,6 +146,43 @@ class TestEmitterNotices(unittest.TestCase):
|
||||
"skipped_detections", scope="front_door", params={"pct": 12.5}
|
||||
)
|
||||
|
||||
def test_high_cpu_raises_a_notice_per_process(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
for uptime, now in ((300, 0.0), (360, 60.0)):
|
||||
stats_emitter._update_notices(
|
||||
self._stats(uptime, 0.0, ffmpeg_cpu="25.0", detect_cpu="45.0"), now
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.raise_notice.call_args_list,
|
||||
[
|
||||
call("ffmpeg_high_cpu", scope="front_door", params={"cpu": 25.0}),
|
||||
call("detect_high_cpu", scope="front_door", params={"cpu": 45.0}),
|
||||
],
|
||||
)
|
||||
|
||||
def test_cpu_below_each_threshold_raises_nothing(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
for uptime, now in ((300, 0.0), (360, 60.0)):
|
||||
stats_emitter._update_notices(
|
||||
self._stats(uptime, 0.0, ffmpeg_cpu="19.0", detect_cpu="39.0"), now
|
||||
)
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
|
||||
def test_missing_cpu_stats_raise_nothing(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
for uptime, now in ((300, 0.0), (360, 60.0)):
|
||||
stats = self._stats(uptime, 0.0)
|
||||
del stats["cpu_usages"]
|
||||
stats_emitter._update_notices(stats, now)
|
||||
|
||||
self.raise_notice.assert_not_called()
|
||||
self.assertEqual(self.flush_notices.call_count, 2)
|
||||
|
||||
def test_startup_window_is_ignored(self):
|
||||
stats_emitter = self._emitter()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user