From 9664d9ceaebdf17e8801d943935440bd9ec0ecb3 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:39:18 -0500 Subject: [PATCH] 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 --- docs/docs/troubleshooting/faqs.md | 2 +- docs/static/frigate-api.yaml | 98 ++++- frigate/api/notices.py | 72 +++- frigate/models.py | 11 +- frigate/notices/registry.py | 153 +++++-- frigate/notices/types.py | 16 +- frigate/stats/emitter.py | 76 +++- frigate/test/http_api/test_http_notices.py | 106 ++--- frigate/test/test_notice_registry.py | 178 +++++--- frigate/test/test_skipped_detections.py | 104 ++++- migrations/038_create_notices.py | 9 +- web/e2e/helpers/api-mocker.ts | 6 +- web/e2e/specs/system-health.spec.ts | 379 ++++++++++++------ web/public/locales/en/views/system.json | 28 +- web/src/components/StatusMessageList.tsx | 78 ++++ web/src/components/Statusbar.tsx | 130 +++--- .../components/health/HealthProblemRow.tsx | 52 ++- .../components/health/NoticeFilterButton.tsx | 10 +- web/src/components/health/NoticesPane.tsx | 36 +- .../components/health/StatusBarNotices.tsx | 2 +- web/src/components/navigation/Bottombar.tsx | 108 +---- web/src/context/statusbar-context.ts | 5 +- web/src/context/statusbar-provider.tsx | 9 +- web/src/hooks/use-health-problems.ts | 134 ++++--- web/src/hooks/use-notices.ts | 60 ++- web/src/hooks/use-stats.ts | 51 +-- web/src/hooks/use-status-messages.ts | 74 ++++ web/src/types/health.ts | 13 +- web/src/types/notice.ts | 13 +- web/src/types/stats.ts | 1 - 30 files changed, 1307 insertions(+), 707 deletions(-) create mode 100644 web/src/components/StatusMessageList.tsx create mode 100644 web/src/hooks/use-status-messages.ts diff --git a/docs/docs/troubleshooting/faqs.md b/docs/docs/troubleshooting/faqs.md index d711ad6dd5..9b00b9d20f 100644 --- a/docs/docs/troubleshooting/faqs.md +++ b/docs/docs/troubleshooting/faqs.md @@ -184,6 +184,6 @@ Filters and masks only hide the incorrect result - they don't teach Frigate what ### Where do I see problems Frigate has detected? -Open System > Health. The Notices list keeps a record of problems Frigate has found, and you can dismiss any entry to acknowledge it. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has undismissed entries. On mobile, tap the warning icon in the bottom navigation bar to see them. +Open System > Health. The Notices list keeps a record of problems Frigate has found. Acknowledge an entry to hide it until the problem happens again, or mute it to hide it for good. Hidden entries stay listed under Show hidden in the filter. Ongoing conditions, such as an offline camera or recordings deleted before their retention period, appear in the status bar for admins until they clear, and the status bar links to the Notices list while it has entries showing. On mobile, tap the warning icon in the bottom navigation bar to see them. The Hardware section below the notices shows whether the detection hardware, hardware acceleration, and enrichment devices in your config were found and are being used, so a GPU that silently fell back to the CPU shows up as a warning. Run stream checks to probe every camera's streams for the same problems the camera wizard reports. diff --git a/docs/static/frigate-api.yaml b/docs/static/frigate-api.yaml index 9563b1cbf4..702c476ab3 100644 --- a/docs/static/frigate-api.yaml +++ b/docs/static/frigate-api.yaml @@ -4174,19 +4174,20 @@ paths: 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 operationId: get_notices_notices_get parameters: - - name: include_dismissed + - name: include_hidden in: query required: false schema: type: boolean default: false - title: Include Dismissed + title: Include Hidden responses: '200': description: Successful Response @@ -4221,16 +4222,16 @@ paths: security: - frigateAdminAuth: [] x-required-role: admin - /notices/dismissed_checks: + /notices/muted_checks: get: tags: - Notices - summary: Get Dismissed Checks + summary: Get Muted Checks description: |- **Access:** Admin role required. - Get the dismissed config and stream check rows, newest first. - operationId: get_dismissed_checks_notices_dismissed_checks_get + Get the muted config and stream check rows, newest first. + operationId: get_muted_checks_notices_muted_checks_get responses: '200': description: Successful Response @@ -4240,16 +4241,16 @@ paths: security: - frigateAdminAuth: [] x-required-role: admin - /notices/dismissed: + /notices/hidden: delete: tags: - Notices - summary: Purge Dismissed + summary: Unhide All Notices description: |- **Access:** Admin role required. - Delete every dismissed notice and check row so each can show again. - operationId: purge_dismissed_notices_dismissed_delete + Show every acknowledged and muted notice and check row again. + operationId: unhide_all_notices_notices_hidden_delete responses: '200': description: Successful Response @@ -4259,18 +4260,83 @@ paths: security: - frigateAdminAuth: [] x-required-role: admin - /notices/{notice_id}/dismiss: + /notices/{notice_id}/acknowledge: post: tags: - Notices - summary: Dismiss Notice + summary: Acknowledge Notice description: |- **Access:** Admin role required. - Hide a notice or a config or stream check row. + Hide a notice until it happens again. - It stays hidden if the same problem happens again. - operationId: dismiss_notice_notices__notice_id__dismiss_post + Config and stream check rows and the update notice never repeat, so they + can only be muted. + operationId: acknowledge_notice_notices__notice_id__acknowledge_post + parameters: + - name: notice_id + in: path + required: true + schema: + type: string + title: Notice Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /notices/{notice_id}/mute: + post: + tags: + - Notices + summary: Mute Notice + description: |- + **Access:** Admin role required. + + Hide a notice or a config or stream check row for good. + operationId: mute_notice_notices__notice_id__mute_post + parameters: + - name: notice_id + in: path + required: true + schema: + type: string + title: Notice Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - frigateAdminAuth: [] + x-required-role: admin + /notices/{notice_id}/hidden: + delete: + tags: + - Notices + summary: Unhide Notice + description: |- + **Access:** Admin role required. + + Show an acknowledged or muted notice or check row again. + operationId: unhide_notice_notices__notice_id__hidden_delete parameters: - name: notice_id in: path diff --git a/frigate/api/notices.py b/frigate/api/notices.py index f61b12d2bf..318ccd01a4 100644 --- a/frigate/api/notices.py +++ b/frigate/api/notices.py @@ -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"}) diff --git a/frigate/models.py b/frigate/models.py index 7346a1fc95..f3b5346858 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -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) diff --git a/frigate/notices/registry.py b/frigate/notices/registry.py index cb8f304f47..a3e0d4cd78 100644 --- a/frigate/notices/registry.py +++ b/frigate/notices/registry.py @@ -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. - 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, ) diff --git a/frigate/notices/types.py b/frigate/notices/types.py index 8594386d42..690c649ebf 100644 --- a/frigate/notices/types.py +++ b/frigate/notices/types.py @@ -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"}) diff --git a/frigate/stats/emitter.py b/frigate/stats/emitter.py index ef4bc4b66d..b140ab0f6f 100644 --- a/frigate/stats/emitter.py +++ b/frigate/stats/emitter.py @@ -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"]) diff --git a/frigate/test/http_api/test_http_notices.py b/frigate/test/http_api/test_http_notices.py index 8d8ac83919..a34258b44a 100644 --- a/frigate/test/http_api/test_http_notices.py +++ b/frigate/test/http_api/test_http_notices.py @@ -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, []) diff --git a/frigate/test/test_notice_registry.py b/frigate/test/test_notice_registry.py index c888bdda4a..75839be6e3 100644 --- a/frigate/test/test_notice_registry.py +++ b/frigate/test/test_notice_registry.py @@ -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"]) diff --git a/frigate/test/test_skipped_detections.py b/frigate/test/test_skipped_detections.py index a66c7f5604..10637b56a2 100644 --- a/frigate/test/test_skipped_detections.py +++ b/frigate/test/test_skipped_detections.py @@ -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() diff --git a/migrations/038_create_notices.py b/migrations/038_create_notices.py index 5ab92c5846..a797e76e96 100644 --- a/migrations/038_create_notices.py +++ b/migrations/038_create_notices.py @@ -18,18 +18,21 @@ def migrate(migrator, database, fake=False, **kwargs): '"first_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, ' '"count" INTEGER NOT NULL, ' - '"dismissed_at" DATETIME)' + '"acknowledged_at" DATETIME, ' + '"muted_at" DATETIME)' ) migrator.sql('CREATE INDEX IF NOT EXISTS "notice_kind" ON "notice" ("kind")') migrator.sql( 'CREATE TABLE IF NOT EXISTS "noticestats" (' '"kind" VARCHAR(50) NOT NULL PRIMARY KEY, ' '"occurrences" INTEGER NOT NULL, ' - '"dismissals" INTEGER NOT NULL, ' + '"acknowledgements" INTEGER NOT NULL, ' + '"mutes" INTEGER NOT NULL, ' '"first_seen" DATETIME NOT NULL, ' '"last_seen" DATETIME NOT NULL, ' '"reported_occurrences" INTEGER NOT NULL DEFAULT 0, ' - '"reported_dismissals" INTEGER NOT NULL DEFAULT 0)' + '"reported_acknowledgements" INTEGER NOT NULL DEFAULT 0, ' + '"reported_mutes" INTEGER NOT NULL DEFAULT 0)' ) diff --git a/web/e2e/helpers/api-mocker.ts b/web/e2e/helpers/api-mocker.ts index a61deffb62..da1d19f180 100644 --- a/web/e2e/helpers/api-mocker.ts +++ b/web/e2e/helpers/api-mocker.ts @@ -49,7 +49,7 @@ export interface ApiMockOverrides { }; users?: { username: string; role: string }[]; notices?: unknown[]; - dismissedChecks?: unknown[]; + mutedChecks?: unknown[]; /** camera name to the ffprobe entries returned for `paths=camera:` */ ffprobe?: Record; } @@ -238,8 +238,8 @@ export class ApiMocker { await this.page.route("**/api/notices", (route) => route.fulfill({ json: overrides?.notices ?? [] }), ); - await this.page.route("**/api/notices/dismissed_checks", (route) => - route.fulfill({ json: overrides?.dismissedChecks ?? [] }), + await this.page.route("**/api/notices/muted_checks", (route) => + route.fulfill({ json: overrides?.mutedChecks ?? [] }), ); // Users. GET lists them; POST/PUT (create, password) just succeed, so diff --git a/web/e2e/specs/system-health.spec.ts b/web/e2e/specs/system-health.spec.ts index cc415a39cf..4d9ac707d4 100644 --- a/web/e2e/specs/system-health.spec.ts +++ b/web/e2e/specs/system-health.spec.ts @@ -1,7 +1,8 @@ /** * Health tab tests -- MEDIUM tier. * - * Default tab, notice list rendering, dismiss, empty state, update notice. + * Default tab, notice list rendering, acknowledge and mute, empty state, + * update notice. */ import { test, expect } from "../fixtures/frigate-test"; @@ -23,7 +24,9 @@ const ERROR_NOTICE = { first_seen: NOW - 600, last_seen: NOW, count: 2, - dismissed_at: null, + acknowledgeable: true, + acknowledged_at: null, + muted_at: null, }; const EVENT_NOTICE = { @@ -37,7 +40,9 @@ const EVENT_NOTICE = { first_seen: NOW - 7200, last_seen: NOW - 60, count: 3, - dismissed_at: null, + acknowledgeable: true, + acknowledged_at: null, + muted_at: null, }; test.describe("System — Health tab @medium", () => { @@ -63,54 +68,61 @@ test.describe("System — Health tab @medium", () => { "Downloading model.onnx for yolo failed: timeout", ); await expect(rows.nth(0)).toContainText("2 times"); - await expect( - rows.nth(0).getByRole("button", { name: "Dismiss" }), - ).toBeVisible(); await expect(rows.nth(1)).toContainText("Detector ov was restarted"); await expect(rows.nth(1)).toContainText("3 times"); - await expect( - rows.nth(1).getByRole("button", { name: "Dismiss" }), - ).toBeVisible(); + for (const index of [0, 1]) { + await expect( + rows.nth(index).getByRole("button", { name: "Acknowledge" }), + ).toBeVisible(); + await expect( + rows.nth(index).getByRole("button", { name: "Mute", exact: true }), + ).toBeVisible(); + } }); - test("dismiss posts and removes the row", async ({ frigateApp }) => { - await frigateApp.installDefaults({ - stats: QUIET_STATS, - notices: [EVENT_NOTICE], + for (const action of ["acknowledge", "mute"] as const) { + test(`${action} posts and removes the row`, async ({ frigateApp }) => { + await frigateApp.installDefaults({ + stats: QUIET_STATS, + notices: [EVENT_NOTICE], + }); + + // the list shrinks after the POST so the refetch shows the row gone + let hidden = false; + await frigateApp.page.route("**/api/notices", (route) => + route.fulfill({ json: hidden ? [] : [EVENT_NOTICE] }), + ); + await frigateApp.page.route( + `**/api/notices/detector_stuck/${action}`, + (route) => { + hidden = true; + return route.fulfill({ json: { success: true } }); + }, + ); + + await frigateApp.goto("/system#health"); + const request = frigateApp.page.waitForRequest( + (req) => + req.url().includes(`/api/notices/detector_stuck/${action}`) && + req.method() === "POST", + ); + await frigateApp.page + .getByTestId("health-problem-notice:detector_stuck") + .getByRole("button", { + name: action === "mute" ? "Mute" : "Acknowledge", + exact: true, + }) + .click(); + await request; + + await expect( + frigateApp.page.locator("[data-testid^='health-problem-']"), + ).toHaveCount(0, { timeout: 5_000 }); + await expect( + frigateApp.page.getByText("Your Frigate installation is healthy"), + ).toBeVisible(); }); - - // the list shrinks after the dismiss so the refetch shows the row gone - let dismissed = false; - await frigateApp.page.route("**/api/notices", (route) => - route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }), - ); - await frigateApp.page.route( - "**/api/notices/detector_stuck/dismiss", - (route) => { - dismissed = true; - return route.fulfill({ json: { success: true } }); - }, - ); - - await frigateApp.goto("/system#health"); - const request = frigateApp.page.waitForRequest( - (req) => - req.url().includes("/api/notices/detector_stuck/dismiss") && - req.method() === "POST", - ); - await frigateApp.page - .getByTestId("health-problem-notice:detector_stuck") - .getByRole("button", { name: "Dismiss" }) - .click(); - await request; - - await expect( - frigateApp.page.locator("[data-testid^='health-problem-']"), - ).toHaveCount(0, { timeout: 5_000 }); - await expect( - frigateApp.page.getByText("Your Frigate installation is healthy"), - ).toBeVisible(); - }); + } test("empty state with no notices", async ({ frigateApp }) => { await frigateApp.installDefaults({ stats: QUIET_STATS }); @@ -140,7 +152,9 @@ test.describe("System — Health tab @medium", () => { first_seen: NOW - 3600, last_seen: NOW, count: 1, - dismissed_at: null, + acknowledgeable: false, + acknowledged_at: null, + muted_at: null, }, ], }); @@ -156,10 +170,23 @@ test.describe("System — Health tab @medium", () => { "href", "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0", ); - await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible(); + await expect( + row.getByRole("button", { name: "Mute", exact: true }), + ).toBeVisible(); + await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount( + 0, + ); }); - test("the filter shows dismissed notices without a Dismiss button", async ({ + const ACKNOWLEDGED_NOTICE = { + ...EVENT_NOTICE, + id: "detector_stuck:coral", + params: { detector: "coral" }, + acknowledged_at: NOW - 60, + }; + const MUTED_NOTICE = { ...ERROR_NOTICE, muted_at: NOW - 120 }; + + test("the filter shows hidden notices marked by how they were hidden", async ({ frigateApp, }) => { await frigateApp.installDefaults({ @@ -169,33 +196,85 @@ test.describe("System — Health tab @medium", () => { await frigateApp.page.route( (url) => url.pathname.endsWith("/api/notices") && - url.searchParams.get("include_dismissed") === "true", + url.searchParams.get("include_hidden") === "true", (route) => route.fulfill({ - json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }], + json: [EVENT_NOTICE, ACKNOWLEDGED_NOTICE, MUTED_NOTICE], }), ); await frigateApp.goto("/system#health"); await frigateApp.page.getByRole("button", { name: "Filter" }).click(); - const showDismissed = frigateApp.page.getByRole("switch", { - name: "Show dismissed", + const showHidden = frigateApp.page.getByRole("switch", { + name: "Show hidden", }); - await expect(showDismissed).toHaveAttribute("aria-checked", "false"); - await showDismissed.click(); + await expect(showHidden).toHaveAttribute("aria-checked", "false"); + await showHidden.click(); + // mobile opens the filter as a modal drawer, which hides the rows' roles + await frigateApp.page.keyboard.press("Escape"); - const row = frigateApp.page.getByTestId( + const acknowledged = frigateApp.page.getByTestId( + "health-problem-notice:detector_stuck:coral", + ); + await expect(acknowledged).toBeVisible({ timeout: 15_000 }); + await expect(acknowledged).toContainText("Acknowledged"); + await expect( + acknowledged.getByRole("button", { name: "Show again" }), + ).toBeVisible(); + await expect( + acknowledged.getByRole("button", { name: "Acknowledge" }), + ).toHaveCount(0); + + const muted = frigateApp.page.getByTestId( "health-problem-notice:model_download_failed:yolo/model.onnx", ); - await expect(row).toBeVisible({ timeout: 15_000 }); - await expect(row).toContainText("Dismissed"); - await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0); + await expect(muted).toContainText("Muted"); + await expect(muted.getByRole("button", { name: "Unmute" })).toBeVisible(); + await expect( + muted.getByRole("button", { name: "Mute", exact: true }), + ).toHaveCount(0); - await showDismissed.click(); - await expect(row).toHaveCount(0); + await frigateApp.page.getByRole("button", { name: "Filter" }).click(); + await showHidden.click(); + await expect(muted).toHaveCount(0); }); - test("clear dismissed deletes the dismissed rows after confirming", async ({ + test("unmute deletes the row's hidden state", async ({ frigateApp }) => { + await frigateApp.installDefaults({ stats: QUIET_STATS, notices: [] }); + await frigateApp.page.route( + (url) => + url.pathname.endsWith("/api/notices") && + url.searchParams.get("include_hidden") === "true", + (route) => route.fulfill({ json: [MUTED_NOTICE] }), + ); + await frigateApp.page.route( + "**/api/notices/model_download_failed:yolo/model.onnx/hidden", + (route) => route.fulfill({ json: { success: true } }), + ); + await frigateApp.goto("/system#health"); + + await frigateApp.page.getByRole("button", { name: "Filter" }).click(); + await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click(); + await frigateApp.page.keyboard.press("Escape"); + + const request = frigateApp.page.waitForRequest( + (req) => + req + .url() + .endsWith( + "/api/notices/model_download_failed:yolo/model.onnx/hidden", + ) && req.method() === "DELETE", + ); + await frigateApp.page + .getByTestId( + "health-problem-notice:model_download_failed:yolo/model.onnx", + ) + .getByRole("button", { name: "Unmute" }) + .click({ timeout: 15_000 }); + await request; + }); + + test("show all again unhides every row after confirming", async ({ frigateApp, }) => { await frigateApp.installDefaults({ @@ -203,29 +282,25 @@ test.describe("System — Health tab @medium", () => { notices: [EVENT_NOTICE], }); - // the history loses its dismissed row once the DELETE lands + // the hidden list loses its muted row once the DELETE lands let cleared = false; await frigateApp.page.route( (url) => url.pathname.endsWith("/api/notices") && - url.searchParams.get("include_dismissed") === "true", + url.searchParams.get("include_hidden") === "true", (route) => route.fulfill({ - json: cleared - ? [EVENT_NOTICE] - : [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }], + json: cleared ? [EVENT_NOTICE] : [EVENT_NOTICE, MUTED_NOTICE], }), ); - await frigateApp.page.route("**/api/notices/dismissed", (route) => { + await frigateApp.page.route("**/api/notices/hidden", (route) => { cleared = true; return route.fulfill({ json: { success: true } }); }); await frigateApp.goto("/system#health"); await frigateApp.page.getByRole("button", { name: "Filter" }).click(); - await frigateApp.page - .getByRole("switch", { name: "Show dismissed" }) - .click(); + await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click(); const row = frigateApp.page.getByTestId( "health-problem-notice:model_download_failed:yolo/model.onnx", ); @@ -233,23 +308,20 @@ test.describe("System — Health tab @medium", () => { await frigateApp.page.keyboard.press("Escape"); await frigateApp.page - .getByRole("button", { name: "Clear dismissed" }) + .getByRole("button", { name: "Show all again" }) .click(); const request = frigateApp.page.waitForRequest( (req) => - req.url().endsWith("/api/notices/dismissed") && - req.method() === "DELETE", + req.url().endsWith("/api/notices/hidden") && req.method() === "DELETE", ); await frigateApp.page .getByRole("alertdialog") - .getByRole("button", { name: "Clear dismissed" }) + .getByRole("button", { name: "Show all again" }) .click(); await request; await expect(row).toHaveCount(0); - await expect( - frigateApp.page.getByText("No dismissed notices"), - ).toBeVisible(); + await expect(frigateApp.page.getByText("No hidden notices")).toBeVisible(); }); test("severity switches hide notices of that severity", async ({ @@ -290,7 +362,9 @@ test.describe("System — Health tab @medium", () => { first_seen: start, last_seen: start + 60, count, - dismissed_at: null, + acknowledgeable: true, + acknowledged_at: null, + muted_at: null, }); await frigateApp.installDefaults({ stats: QUIET_STATS, @@ -332,7 +406,9 @@ test.describe("System — Health tab @medium", () => { first_seen: NOW - 600, last_seen: NOW - 600, count: 1, - dismissed_at: null, + acknowledgeable: true, + acknowledged_at: null, + muted_at: null, }, ], }); @@ -363,7 +439,9 @@ test.describe("System — Health tab @medium", () => { first_seen: NOW - 600, last_seen: NOW - 600, count: 1, - dismissed_at: null, + acknowledgeable: true, + acknowledged_at: null, + muted_at: null, }, ], }); @@ -695,7 +773,7 @@ test.describe("System — Health notices sources @medium", () => { // the status bar shows a problem, so stats have loaded await expect( - frigateApp.page.getByText("Front Door is offline"), + frigateApp.page.getByText("Recordings are being deleted"), ).toBeVisible({ timeout: 15_000 }); await expect( frigateApp.page.locator("[data-testid^='health-problem-']"), @@ -1046,7 +1124,7 @@ test.describe("System — Health notices sources @medium", () => { await expect(frigateApp.page).toHaveURL(/\/system#health/); }); - test("status bar counts undismissed notices next to the health text", async ({ + test("status bar counts shown notices next to the health text", async ({ frigateApp, }) => { test.skip(frigateApp.isMobile, "Status bar is desktop-only"); @@ -1089,41 +1167,61 @@ test.describe("System — Health notices sources @medium", () => { await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0); }); - test("a config row can be dismissed", async ({ frigateApp }) => { - const id = "config:detect:fps-greater-than-five:camera.garage"; - await frigateApp.installDefaults({ - config: { - cameras: { - garage: { detect: { width: 2560, height: 1440, fps: 10 } }, - }, - }, - stats: QUIET_STATS, - }); + // the slow detector warning is added before the offline error + const TWO_PROBLEM_STATS = { + detectors: { cpu: { inference_speed: 60 } }, + cameras: { front_door: { camera_fps: 0 } }, + }; - // the list gains the dismissal after the POST so the refetch hides the row - let dismissed = false; - await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => { - dismissed = true; - return route.fulfill({ json: { success: true } }); - }); - await frigateApp.page.route("**/api/notices/dismissed_checks", (route) => - route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }), - ); - await frigateApp.goto("/system#health"); + test("status bar collapses several problems behind the most severe", async ({ + frigateApp, + }) => { + test.skip(frigateApp.isMobile, "Status bar is desktop-only"); + await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS }); + await frigateApp.goto("/"); - const row = frigateApp.page.getByTestId(`health-problem-${id}`); - await expect(row).toBeVisible({ timeout: 15_000 }); - const request = frigateApp.page.waitForRequest( - (req) => - req.url().includes(`/api/notices/${id}/dismiss`) && - req.method() === "POST", - ); - await row.getByRole("button", { name: "Dismiss" }).click(); - await request; - await expect(row).toHaveCount(0); + const summary = frigateApp.page.getByRole("button", { + name: "Front Door is offline +1", + }); + await expect(summary).toBeVisible({ timeout: 15_000 }); + await expect(frigateApp.page.getByText("Cpu is slow")).toHaveCount(0); + + await summary.click(); + const list = frigateApp.page.getByTestId("status-message-list"); + await expect(list.getByRole("link")).toHaveText([ + "Front Door is offline", + "Cpu is slow (60 ms)", + ]); + + await list.getByRole("link", { name: "Cpu is slow (60 ms)" }).click(); + await expect(frigateApp.page).toHaveURL(/\/system#general/); + await expect(list).toHaveCount(0); }); - test("dismissed config rows move to the dismissed list", async ({ + test("mobile status drawer stacks every problem", async ({ frigateApp }) => { + test.skip(!frigateApp.isMobile, "Mobile-only"); + await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS }); + await frigateApp.goto("/"); + + await frigateApp.page + .getByTestId("status-alert-trigger") + .click({ timeout: 15_000 }); + const items = frigateApp.page + .getByTestId("status-message-list") + .getByRole("link"); + await expect(items).toHaveText([ + "Front Door is offline", + "Cpu is slow (60 ms)", + ]); + + const [first, second] = await Promise.all([ + items.nth(0).boundingBox(), + items.nth(1).boundingBox(), + ]); + expect(second!.y).toBeGreaterThan(first!.y); + }); + + test("a config row can be muted but not acknowledged", async ({ frigateApp, }) => { const id = "config:detect:fps-greater-than-five:camera.garage"; @@ -1134,12 +1232,49 @@ test.describe("System — Health notices sources @medium", () => { }, }, stats: QUIET_STATS, - dismissedChecks: [{ id, dismissed_at: NOW - 120 }], + }); + + // the list gains the mute after the POST so the refetch hides the row + let muted = false; + await frigateApp.page.route(`**/api/notices/${id}/mute`, (route) => { + muted = true; + return route.fulfill({ json: { success: true } }); + }); + await frigateApp.page.route("**/api/notices/muted_checks", (route) => + route.fulfill({ json: muted ? [{ id, muted_at: NOW }] : [] }), + ); + await frigateApp.goto("/system#health"); + + const row = frigateApp.page.getByTestId(`health-problem-${id}`); + await expect(row).toBeVisible({ timeout: 15_000 }); + await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount( + 0, + ); + const request = frigateApp.page.waitForRequest( + (req) => + req.url().includes(`/api/notices/${id}/mute`) && + req.method() === "POST", + ); + await row.getByRole("button", { name: "Mute", exact: true }).click(); + await request; + await expect(row).toHaveCount(0); + }); + + test("muted config rows move to the hidden list", async ({ frigateApp }) => { + const id = "config:detect:fps-greater-than-five:camera.garage"; + await frigateApp.installDefaults({ + config: { + cameras: { + garage: { detect: { width: 2560, height: 1440, fps: 10 } }, + }, + }, + stats: QUIET_STATS, + mutedChecks: [{ id, muted_at: NOW - 120 }], }); await frigateApp.page.route( (url) => url.pathname.endsWith("/api/notices") && - url.searchParams.get("include_dismissed") === "true", + url.searchParams.get("include_hidden") === "true", (route) => route.fulfill({ json: [] }), ); await frigateApp.goto("/system#health"); @@ -1153,12 +1288,14 @@ test.describe("System — Health notices sources @medium", () => { await expect(row).toHaveCount(0); await frigateApp.page.getByRole("button", { name: "Filter" }).click(); - await frigateApp.page - .getByRole("switch", { name: "Show dismissed" }) - .click(); + await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click(); + await frigateApp.page.keyboard.press("Escape"); await expect(row).toBeVisible(); - await expect(row).toContainText("Dismissed"); - await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0); + await expect(row).toContainText("Muted"); + await expect(row.getByRole("button", { name: "Unmute" })).toBeVisible(); + await expect( + row.getByRole("button", { name: "Mute", exact: true }), + ).toHaveCount(0); }); }); diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index 3435b645b9..3be39e04c2 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -19,20 +19,26 @@ "notices": { "title": "Notices", "empty": "Your Frigate installation is healthy", - "dismiss": "Dismiss", + "acknowledge": "Acknowledge", + "acknowledgeHint": "Hide until this happens again", + "mute": "Mute", + "muteHint": "Never show this again", + "unmute": "Unmute", + "showAgain": "Show again", "openSettings": "Open settings", "openLink": "Open link", "noMatches": "No notices match the filter", - "dismissedTitle": "Dismissed", - "noneDismissed": "No dismissed notices", - "clearDismissed": "Clear dismissed", - "clearDismissedTitle": "Clear dismissed notices?", - "clearDismissedDesc": "Every dismissed notice is deleted. Config and stream checks show again right away, and other notices return the next time they happen.", + "hiddenTitle": "Hidden", + "noneHidden": "No hidden notices", + "showAll": "Show all again", + "showAllTitle": "Show all hidden notices?", + "showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.", "firstSeen_one": "First seen {{time}}", "firstSeen_other": "First seen {{time}} · {{count}} times", - "dismissedAt": "Dismissed {{time}}", + "acknowledgedAt": "Acknowledged {{time}}", + "mutedAt": "Muted {{time}}", "filter": { - "showDismissed": "Show dismissed", + "showHidden": "Show hidden", "severity": "Severity", "error": "Error", "warning": "Warning", @@ -42,6 +48,8 @@ "detector_stuck": "Detector {{detector}} was restarted after it stopped responding", "model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}", "skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute", + "ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)", + "detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)", "shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB", "failed_login_one": "Failed login attempt for {{user}}", "failed_login_other": "Failed login attempts for {{user}}", @@ -324,12 +332,12 @@ }, "lastRefreshed": "Last refreshed: ", "stats": { - "ffmpegHighCpuUsage": "{{camera}} has high FFmpeg CPU usage ({{ffmpegAvg}}%)", "cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames", - "detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)", "healthy": "System is healthy", "systemNotices_one": "{{count}} system notice", "systemNotices_other": "{{count}} system notices", + "moreMessages_one": "+{{count}}", + "moreMessages_other": "+{{count}}", "reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)", "cameraIsOffline": "{{camera}} is offline", "detectIsSlow": "{{detect}} is slow ({{speed}} ms)", diff --git a/web/src/components/StatusMessageList.tsx b/web/src/components/StatusMessageList.tsx new file mode 100644 index 0000000000..46b7cdbc4b --- /dev/null +++ b/web/src/components/StatusMessageList.tsx @@ -0,0 +1,78 @@ +import { StatusMessage } from "@/context/statusbar-context"; +import { cn } from "@/lib/utils"; +import { ProblemSeverity } from "@/types/stats"; +import { IoIosWarning } from "react-icons/io"; +import { Link } from "react-router-dom"; + +const SEVERITY_COLOR: Record = { + error: "text-danger", + warning: "text-orange-400", + info: "text-selected", +}; + +type StatusMessageItemProps = { + message: StatusMessage; + className?: string; + onNavigate?: () => void; +}; + +/** One status bar message, a link when it has one. */ +export function StatusMessageItem({ + message, + className, + onNavigate, +}: StatusMessageItemProps) { + const content = ( +
+ + {message.text} +
+ ); + + if (!message.link) { + return content; + } + + return ( + + {content} + + ); +} + +type StatusMessageListProps = { + messages: StatusMessage[]; + className?: string; + onNavigate?: () => void; +}; + +/** Status bar messages stacked one per line. */ +export default function StatusMessageList({ + messages, + className, + onNavigate, +}: StatusMessageListProps) { + return ( +
+ {messages.map((message, index) => ( + // ids are unique only within a message key + + ))} +
+ ); +} diff --git a/web/src/components/Statusbar.tsx b/web/src/components/Statusbar.tsx index 4de414e911..542d766abe 100644 --- a/web/src/components/Statusbar.tsx +++ b/web/src/components/Statusbar.tsx @@ -1,20 +1,24 @@ -import { useEmbeddingsReindexProgress } from "@/api/ws"; +import { StatusMessage } from "@/context/statusbar-context"; +import { useAutoFrigateStats } from "@/hooks/use-stats"; +import useStatusMessages from "@/hooks/use-status-messages"; +import StatusMessageList, { + StatusMessageItem, +} from "@/components/StatusMessageList"; import { - StatusBarMessagesContext, - StatusMessage, -} from "@/context/statusbar-context"; -import useStats, { useAutoFrigateStats } from "@/hooks/use-stats"; + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import StatusBarNotices from "@/components/health/StatusBarNotices"; import { cn } from "@/lib/utils"; import type { ProfilesApiResponse } from "@/types/profile"; import { getProfileColor } from "@/utils/profileColors"; import { useIsAdmin } from "@/hooks/use-is-admin"; -import { useContext, useEffect, useMemo } from "react"; +import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import useSWR from "swr"; import { FaCheck } from "react-icons/fa"; -import { IoIosWarning } from "react-icons/io"; import { MdCircle } from "react-icons/md"; import { Link } from "react-router-dom"; @@ -22,9 +26,7 @@ export default function Statusbar() { const { t } = useTranslation(["views/system"]); const isAdmin = useIsAdmin(); - const { messages, addMessage, clearMessages } = useContext( - StatusBarMessagesContext, - )!; + const messages = useStatusMessages(); const stats = useAutoFrigateStats(); @@ -38,21 +40,6 @@ export default function Statusbar() { return parseInt(systemCpu); }, [stats]); - const { potentialProblems } = useStats(stats); - - useEffect(() => { - clearMessages("stats"); - potentialProblems.forEach((problem) => { - addMessage( - "stats", - problem.text, - problem.color, - undefined, - problem.relevantLink, - ); - }); - }, [potentialProblems, addMessage, clearMessages]); - const { data: profilesData } = useSWR("profiles"); const activeProfile = useMemo(() => { @@ -68,28 +55,6 @@ export default function Statusbar() { }; }, [profilesData]); - const { payload: reindexState } = useEmbeddingsReindexProgress(); - - useEffect(() => { - if (reindexState) { - if (reindexState.status == "indexing") { - clearMessages("embeddings-reindex"); - addMessage( - "embeddings-reindex", - t("stats.reindexingEmbeddings", { - processed: Math.floor( - (reindexState.processed_objects / reindexState.total_objects) * - 100, - ), - }), - ); - } - if (reindexState.status === "completed") { - clearMessages("embeddings-reindex"); - } - } - }, [reindexState, addMessage, clearMessages, t]); - return (
@@ -187,42 +152,57 @@ export default function Statusbar() { ))}
- {!isAdmin ? null : Object.entries(messages).length === 0 ? ( + {!isAdmin ? null : messages.length === 0 ? ( {t("stats.healthy")} + ) : messages.length === 1 ? ( + ) : ( - Object.entries(messages).map(([key, messageArray]) => ( -
- {messageArray.map(({ text, color, link }: StatusMessage) => { - const message = ( -
- - {text} -
- ); - - if (link) { - return ( - - {message} - - ); - } else { - return message; - } - })} -
- )) + )} {isAdmin && }
); } + +type StatusMessagesPopoverProps = { + messages: StatusMessage[]; +}; + +/** The most severe message and a count of the rest, which open the full list. */ +function StatusMessagesPopover({ messages }: StatusMessagesPopoverProps) { + const { t } = useTranslation(["views/system"]); + const [open, setOpen] = useState(false); + const [first, ...rest] = messages; + + return ( + + + + + + setOpen(false)} + /> + + + ); +} diff --git a/web/src/components/health/HealthProblemRow.tsx b/web/src/components/health/HealthProblemRow.tsx index e153f9107d..209d5ca224 100644 --- a/web/src/components/health/HealthProblemRow.tsx +++ b/web/src/components/health/HealthProblemRow.tsx @@ -2,7 +2,11 @@ import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { FaTriangleExclamation } from "react-icons/fa6"; import { + LuBell, + LuBellOff, + LuCheck, LuExternalLink, + LuEye, LuInfo, LuSlidersHorizontal, LuX, @@ -51,7 +55,13 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) { problem.link || problem.docLink || problem.externalLink || - problem.onDismiss; + problem.onAcknowledge || + problem.onMute || + problem.onUnhide; + const unhideLabel = + problem.hidden === "muted" + ? t("health.notices.unmute") + : t("health.notices.showAgain"); return (
)} - {problem.onDismiss && ( - + {problem.onAcknowledge && ( + + + )} + {problem.onMute && ( + + + + )} + {problem.onUnhide && ( + + )} diff --git a/web/src/components/health/NoticeFilterButton.tsx b/web/src/components/health/NoticeFilterButton.tsx index beeda5f79f..646700b0eb 100644 --- a/web/src/components/health/NoticeFilterButton.tsx +++ b/web/src/components/health/NoticeFilterButton.tsx @@ -24,7 +24,7 @@ export default function NoticeFilterButton({ const { t } = useTranslation(["views/system", "components/filter"]); const [open, setOpen] = useState(false); const active = - filter.showDismissed || + filter.showHidden || filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length; const severityLabels: Record = { @@ -59,10 +59,10 @@ export default function NoticeFilterButton({ const content = (
- onFilterChange({ ...filter, showDismissed }) + label={t("health.notices.filter.showHidden")} + isChecked={filter.showHidden} + onCheckedChange={(showHidden) => + onFilterChange({ ...filter, showHidden }) } /> diff --git a/web/src/components/health/NoticesPane.tsx b/web/src/components/health/NoticesPane.tsx index 460794b1bc..50ec1f2bba 100644 --- a/web/src/components/health/NoticesPane.tsx +++ b/web/src/components/health/NoticesPane.tsx @@ -23,9 +23,9 @@ type NoticesPaneProps = { export default function NoticesPane({ filter }: NoticesPaneProps) { const { t } = useTranslation(["views/system", "views/settings", "common"]); - const { problems, dismissed, loading, clearDismissed } = useHealthProblems( + const { problems, hidden, loading, unhideAll } = useHealthProblems( t, - filter.showDismissed, + filter.showHidden, ); const [confirmClear, setConfirmClear] = useState(false); @@ -37,12 +37,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) { [problems, filter.severities], ); - const shownDismissed = useMemo( + const shownHidden = useMemo( () => - dismissed?.filter((problem) => - filter.severities.includes(problem.severity), - ), - [dismissed, filter.severities], + hidden?.filter((problem) => filter.severities.includes(problem.severity)), + [hidden, filter.severities], ); return ( @@ -70,32 +68,32 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
)}
- {filter.showDismissed && ( + {filter.showHidden && (
- {t("health.notices.dismissedTitle")} + {t("health.notices.hiddenTitle")}
- {dismissed && dismissed.length > 0 && ( + {hidden && hidden.length > 0 && ( )}
- {shownDismissed === undefined ? ( + {shownHidden === undefined ? ( - ) : shownDismissed.length === 0 ? ( + ) : shownHidden.length === 0 ? (
- {t("health.notices.noneDismissed")} + {t("health.notices.noneHidden")}
) : (
- {shownDismissed.map((problem) => ( + {shownHidden.map((problem) => ( ))}
@@ -107,10 +105,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) { - {t("health.notices.clearDismissedTitle")} + {t("health.notices.showAllTitle")} - {t("health.notices.clearDismissedDesc")} + {t("health.notices.showAllDesc")} @@ -119,9 +117,9 @@ export default function NoticesPane({ filter }: NoticesPaneProps) { - {t("health.notices.clearDismissed")} + {t("health.notices.showAll")} diff --git a/web/src/components/health/StatusBarNotices.tsx b/web/src/components/health/StatusBarNotices.tsx index 1138319617..33e52e5648 100644 --- a/web/src/components/health/StatusBarNotices.tsx +++ b/web/src/components/health/StatusBarNotices.tsx @@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { useHealthProblems } from "@/hooks/use-health-problems"; -/** The count of undismissed Notices rows, shown before the status bar's health text. */ +/** The count of shown Notices rows, shown before the status bar's health text. */ export default function StatusBarNotices() { const { t } = useTranslation(["views/system"]); const { problems, loading } = useHealthProblems(t); diff --git a/web/src/components/navigation/Bottombar.tsx b/web/src/components/navigation/Bottombar.tsx index efe145b30e..0f39e8946e 100644 --- a/web/src/components/navigation/Bottombar.tsx +++ b/web/src/components/navigation/Bottombar.tsx @@ -1,30 +1,15 @@ import NavItem from "./NavItem"; import { IoIosWarning } from "react-icons/io"; import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer"; -import useSWR from "swr"; -import { FrigateStats } from "@/types/stats"; -import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws"; -import { - useContext, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import useStats from "@/hooks/use-stats"; +import { useLayoutEffect, useRef, useState } from "react"; +import useStatusMessages from "@/hooks/use-status-messages"; +import StatusMessageList from "../StatusMessageList"; import { useIsAdmin } from "@/hooks/use-is-admin"; import GeneralSettings from "../menu/GeneralSettings"; import useNavigation from "@/hooks/use-navigation"; -import { - StatusBarMessagesContext, - StatusMessage, -} from "@/context/statusbar-context"; -import { Link } from "react-router-dom"; import { cn } from "@/lib/utils"; import { isMobile } from "react-device-detect"; import { isPWA } from "@/utils/isPWA"; -import { useTranslation } from "react-i18next"; function Bottombar() { const navItems = useNavigation("secondary"); @@ -98,64 +83,12 @@ type StatusAlertNavProps = { large?: boolean; }; function StatusAlertNav({ className, large }: StatusAlertNavProps) { - const { t } = useTranslation(["views/system"]); - const { data: initialStats } = useSWR("stats", { - revalidateOnFocus: false, - }); - const latestStats = useFrigateStats(); - - const { messages, addMessage, clearMessages } = useContext( - StatusBarMessagesContext, - )!; - - const stats = useMemo(() => { - if (latestStats) { - return latestStats; - } - - return initialStats; - }, [initialStats, latestStats]); - const { potentialProblems } = useStats(stats); - - useEffect(() => { - clearMessages("stats"); - potentialProblems.forEach((problem) => { - addMessage( - "stats", - problem.text, - problem.color, - undefined, - problem.relevantLink, - ); - }); - }, [potentialProblems, addMessage, clearMessages]); - - const { payload: reindexState } = useEmbeddingsReindexProgress(); - - useEffect(() => { - if (reindexState) { - if (reindexState.status == "indexing") { - clearMessages("embeddings-reindex"); - addMessage( - "embeddings-reindex", - t("stats.reindexingEmbeddings", { - processed: Math.floor( - (reindexState.processed_objects / reindexState.total_objects) * - 100, - ), - }), - ); - } - if (reindexState.status === "completed") { - clearMessages("embeddings-reindex"); - } - } - }, [reindexState, addMessage, clearMessages, t]); + const messages = useStatusMessages(); const isAdmin = useIsAdmin(); // problems link to admin-only pages - if (!isAdmin || !messages || Object.keys(messages).length === 0) { + if (!isAdmin || messages.length === 0) { return; } @@ -163,6 +96,7 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
-
- {Object.entries(messages).map(([key, messageArray]) => ( -
- {messageArray.map(({ id, text, color, link }: StatusMessage) => { - const message = ( -
- - {text} -
- ); - - if (link) { - return ( - - {message} - - ); - } else { - return message; - } - })} -
- ))} -
+ ); diff --git a/web/src/context/statusbar-context.ts b/web/src/context/statusbar-context.ts index eacf2f7c43..68e2461d11 100644 --- a/web/src/context/statusbar-context.ts +++ b/web/src/context/statusbar-context.ts @@ -1,9 +1,10 @@ import { createContext } from "react"; +import { ProblemSeverity } from "@/types/stats"; export type StatusMessage = { id: string; text: string; - color?: string; + severity: ProblemSeverity; link?: string; }; @@ -16,7 +17,7 @@ type StatusBarMessagesContextValue = { addMessage: ( key: string, message: string, - color?: string, + severity?: ProblemSeverity, messageId?: string, link?: string, ) => string | undefined; diff --git a/web/src/context/statusbar-provider.tsx b/web/src/context/statusbar-provider.tsx index ca05462bce..b5c30d6822 100644 --- a/web/src/context/statusbar-provider.tsx +++ b/web/src/context/statusbar-provider.tsx @@ -3,6 +3,7 @@ import { StatusBarMessagesContext, StatusMessagesState, } from "@/context/statusbar-context"; +import { ProblemSeverity } from "@/types/stats"; type StatusBarMessagesProviderProps = { children: ReactNode; @@ -19,21 +20,21 @@ export function StatusBarMessagesProvider({ ( key: string, message: string, - color?: string, + severity: ProblemSeverity = "error", messageId?: string, link?: string, ) => { if (!key || !message) return; - const id = messageId ?? Date.now().toString(); - const msgColor = color ?? "text-danger"; + // the text is the fallback id, so repeating a message replaces it + const id = messageId ?? message; setMessagesState((prevMessages) => { const existingMessages = prevMessages[key] || []; // Check if a message with the same ID already exists const messageIndex = existingMessages.findIndex((msg) => msg.id === id); - const newMessage = { id, text: message, color: msgColor, link }; + const newMessage = { id, text: message, severity, link }; // If the message exists, replace it, otherwise add the new message let updatedMessages; diff --git a/web/src/hooks/use-health-problems.ts b/web/src/hooks/use-health-problems.ts index 5dc756b19b..9e493b3b18 100644 --- a/web/src/hooks/use-health-problems.ts +++ b/web/src/hooks/use-health-problems.ts @@ -5,25 +5,25 @@ import useSWR from "swr"; import { useDateLocale } from "@/hooks/use-date-locale"; import { useTimezone } from "@/hooks/use-date-utils"; import { useHealthChecks } from "@/hooks/use-health-checks"; -import { useNotices } from "@/hooks/use-notices"; +import { hiddenAt, useNotices } from "@/hooks/use-notices"; import { evaluateConfigHealth } from "@/utils/configHealth"; import { formatUnixTimestampToDateTime } from "@/utils/dateUtil"; import { sortHealthProblems } from "@/utils/healthSort"; import { streamHealth } from "@/utils/streamHealth"; import type { FrigateConfig } from "@/types/frigateConfig"; import type { HealthProblem } from "@/types/health"; -import type { DismissedCheck, Notice } from "@/types/notice"; +import type { MutedCheck, Notice } from "@/types/notice"; const EXTERNAL_LINK = /^https?:\/\//; type HealthProblems = { - /** undismissed rows, most severe first */ + /** shown rows, most severe first */ problems: HealthProblem[]; - /** dismissed rows, most recently dismissed first; undefined until loaded */ - dismissed?: HealthProblem[]; + /** acknowledged and muted rows, most recently hidden first; undefined until loaded */ + hidden?: HealthProblem[]; loading: boolean; - /** delete every dismissed row, so each can show again */ - clearDismissed: () => Promise; + /** show every hidden row again */ + unhideAll: () => Promise; }; /** @@ -34,7 +34,7 @@ type HealthProblems = { */ export function useHealthProblems( t: TFunction, - showDismissed = false, + showHidden = false, ): HealthProblems { const { data: config } = useSWR("config", { revalidateOnFocus: false, @@ -43,30 +43,40 @@ export function useHealthProblems( const locale = useDateLocale(); const { notices, - dismissed: dismissedNotices, - dismiss, - mutateDismissed, - } = useNotices(showDismissed); - const { data: dismissedChecks, mutate: mutateDismissedChecks } = useSWR< - DismissedCheck[] - >("notices/dismissed_checks"); + hidden: hiddenNotices, + acknowledge, + mute, + unhide, + mutateHidden, + } = useNotices(showHidden); + const { data: mutedChecks, mutate: mutateMutedChecks } = useSWR( + "notices/muted_checks", + ); const { stream: { results }, } = useHealthChecks(); - const dismissCheck = useCallback( + const muteCheck = useCallback( async (id: string) => { - await axios.post(`notices/${id}/dismiss`); - mutateDismissedChecks(); + await axios.post(`notices/${id}/mute`); + mutateMutedChecks(); }, - [mutateDismissedChecks], + [mutateMutedChecks], ); - const clearDismissed = useCallback(async () => { - await axios.delete("notices/dismissed"); - mutateDismissed(); - mutateDismissedChecks(); - }, [mutateDismissed, mutateDismissedChecks]); + const unmuteCheck = useCallback( + async (id: string) => { + await axios.delete(`notices/${id}/hidden`); + mutateMutedChecks(); + }, + [mutateMutedChecks], + ); + + const unhideAll = useCallback(async () => { + await axios.delete("notices/hidden"); + mutateHidden(); + mutateMutedChecks(); + }, [mutateHidden, mutateMutedChecks]); const formatTime = useCallback( (timestamp: number) => @@ -98,23 +108,37 @@ export function useHealthProblems( count: notice.count, }), meta: - notice.dismissed_at === null - ? t("health.notices.firstSeen", { + notice.muted_at !== null + ? t("health.notices.mutedAt", { ns: "views/system", - time: formatTime(notice.first_seen), - count: notice.count, + time: formatTime(notice.muted_at), }) - : t("health.notices.dismissedAt", { - ns: "views/system", - time: formatTime(notice.dismissed_at), - }), + : notice.acknowledged_at !== null + ? t("health.notices.acknowledgedAt", { + ns: "views/system", + time: formatTime(notice.acknowledged_at), + }) + : t("health.notices.firstSeen", { + ns: "views/system", + time: formatTime(notice.first_seen), + count: notice.count, + }), link: external ? undefined : link, externalLink: external ? link : undefined, - onDismiss: - notice.dismissed_at === null ? () => dismiss(notice.id) : undefined, + ...(hiddenAt(notice) > 0 + ? { + hidden: notice.muted_at !== null ? "muted" : "acknowledged", + onUnhide: () => unhide(notice.id), + } + : { + onAcknowledge: notice.acknowledgeable + ? () => acknowledge(notice.id) + : undefined, + onMute: () => mute(notice.id), + }), }; }, - [dismiss, formatTime, t], + [acknowledge, mute, unhide, formatTime, t], ); const checks = useMemo( @@ -128,12 +152,10 @@ export function useHealthProblems( [config, results, t], ); - const dismissedAt = useMemo( + const mutedAt = useMemo( () => - new Map( - (dismissedChecks ?? []).map((check) => [check.id, check.dismissed_at]), - ), - [dismissedChecks], + new Map((mutedChecks ?? []).map((check) => [check.id, check.muted_at])), + [mutedChecks], ); const problems = useMemo( @@ -141,27 +163,27 @@ export function useHealthProblems( sortHealthProblems([ ...(notices ?? []).map(noticeRow), ...checks - .filter((check) => !dismissedAt.has(check.id)) + .filter((check) => !mutedAt.has(check.id)) .map((check) => ({ ...check, - onDismiss: () => dismissCheck(check.id), + onMute: () => muteCheck(check.id), })), ]), - [notices, noticeRow, checks, dismissedAt, dismissCheck], + [notices, noticeRow, checks, mutedAt, muteCheck], ); - const dismissed = useMemo(() => { - if (!showDismissed || dismissedNotices === undefined) { + const hidden = useMemo(() => { + if (!showHidden || hiddenNotices === undefined) { return undefined; } const rows = [ - ...dismissedNotices.map((notice) => ({ - at: notice.dismissed_at ?? 0, + ...hiddenNotices.map((notice) => ({ + at: hiddenAt(notice), row: noticeRow(notice), })), ...checks.flatMap((check) => { - const at = dismissedAt.get(check.id); + const at = mutedAt.get(check.id); return at === undefined ? [] @@ -170,10 +192,12 @@ export function useHealthProblems( at, row: { ...check, - meta: t("health.notices.dismissedAt", { + meta: t("health.notices.mutedAt", { ns: "views/system", time: formatTime(at), }), + hidden: "muted" as const, + onUnhide: () => unmuteCheck(check.id), }, }, ]; @@ -182,17 +206,17 @@ export function useHealthProblems( return rows.sort((a, b) => b.at - a.at).map(({ row }) => row); }, [ - showDismissed, - dismissedNotices, + showHidden, + hiddenNotices, noticeRow, checks, - dismissedAt, + mutedAt, formatTime, + unmuteCheck, t, ]); - const loading = - notices === undefined || dismissedChecks === undefined || !config; + const loading = notices === undefined || mutedChecks === undefined || !config; - return { problems, dismissed, loading, clearDismissed }; + return { problems, hidden, loading, unhideAll }; } diff --git a/web/src/hooks/use-notices.ts b/web/src/hooks/use-notices.ts index c0b14d4d86..44140566f6 100644 --- a/web/src/hooks/use-notices.ts +++ b/web/src/hooks/use-notices.ts @@ -4,19 +4,22 @@ import useSWR from "swr"; import { useWs } from "@/api/ws"; import type { Notice } from "@/types/notice"; +/** When a hidden notice was acknowledged or muted. */ +export function hiddenAt(notice: Notice): number { + return notice.muted_at ?? notice.acknowledged_at ?? 0; +} + /** * Active notices come from a REST snapshot, then from every `notices` - * websocket payload. Dismissed notices are fetched only while the history is - * shown, and again when the active list changes or the tab regains focus. A - * purge in another tab leaves the active list unchanged, so only focus - * catches it. + * websocket payload. Hidden notices are fetched only while the hidden list is + * shown, and again when the active list changes or the tab regains focus. */ -export function useNotices(showDismissed: boolean) { +export function useNotices(showHidden: boolean) { const { data: initial, mutate } = useSWR("notices", { revalidateOnFocus: false, }); - const { data: history, mutate: mutateHistory } = useSWR( - showDismissed ? ["notices", { include_dismissed: true }] : null, + const { data: all, mutate: mutateHidden } = useSWR( + showHidden ? ["notices", { include_hidden: true }] : null, ); const { value: { payload }, @@ -30,31 +33,44 @@ export function useNotices(showDismissed: boolean) { [payload], ); - // once a websocket frame has arrived it is the source of truth; a dismiss - // still shows up because the registry publishes a new frame after it + // once a websocket frame has arrived it is the source of truth; every + // acknowledge, mute, and unhide publishes a new frame const notices = live ?? initial; - // refetch the history whenever the active list changes; SWR ignores the - // call while the history is hidden + // refetch the hidden list whenever the active list changes; SWR ignores the + // call while the hidden list is not shown useEffect(() => { - mutateHistory(); - }, [live, mutateHistory]); + mutateHidden(); + }, [live, mutateHidden]); - const dismissed = useMemo( + const hidden = useMemo( () => - history - ?.filter((notice) => notice.dismissed_at !== null) - .sort((a, b) => (b.dismissed_at ?? 0) - (a.dismissed_at ?? 0)), - [history], + all + ?.filter((notice) => hiddenAt(notice) > 0) + .sort((a, b) => hiddenAt(b) - hiddenAt(a)), + [all], ); - const dismiss = useCallback( - async (id: string) => { - await axios.post(`notices/${id}/dismiss`); + const act = useCallback( + async (request: Promise) => { + await request; mutate(); }, [mutate], ); - return { notices, dismissed, dismiss, mutateDismissed: mutateHistory }; + const acknowledge = useCallback( + (id: string) => act(axios.post(`notices/${id}/acknowledge`)), + [act], + ); + const mute = useCallback( + (id: string) => act(axios.post(`notices/${id}/mute`)), + [act], + ); + const unhide = useCallback( + (id: string) => act(axios.delete(`notices/${id}/hidden`)), + [act], + ); + + return { notices, hidden, acknowledge, mute, unhide, mutateHidden }; } diff --git a/web/src/hooks/use-stats.ts b/web/src/hooks/use-stats.ts index 1dbdfd177d..42c72faab5 100644 --- a/web/src/hooks/use-stats.ts +++ b/web/src/hooks/use-stats.ts @@ -1,9 +1,5 @@ import { FrigateConfig } from "@/types/frigateConfig"; -import { - CameraDetectThreshold, - CameraFfmpegThreshold, - InferenceThreshold, -} from "@/types/graph"; +import { InferenceThreshold } from "@/types/graph"; import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats"; import { useMemo } from "react"; import useSWR from "swr"; @@ -15,20 +11,12 @@ import { useIsAdmin } from "./use-is-admin"; import { useTranslation } from "react-i18next"; -// the status bar has always rendered these exact classes; keep them byte for -// byte so its output does not change -const SEVERITY_COLOR: Record = { - error: "text-danger", - warning: "text-orange-400", - info: "text-selected", -}; - function problem( severity: ProblemSeverity, text: string, relevantLink?: string, ): PotentialProblem { - return { text, severity, color: SEVERITY_COLOR[severity], relevantLink }; + return { text, severity, relevantLink }; } // matches SKIPPED_DETECTIONS_PCT in frigate/stats/emitter.py @@ -122,20 +110,13 @@ export default function useStats(stats: FrigateStats | undefined) { } }); - // check camera cpu usages + // check for skipped detections Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => { // Skip replay cameras if (isReplayCamera(name)) { return; } - const ffmpegAvg = parseFloat( - memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average, - ); - const detectAvg = parseFloat( - memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average, - ); - const cameraName = config?.cameras?.[name]?.friendly_name ?? name; if ( @@ -153,32 +134,6 @@ export default function useStats(stats: FrigateStats | undefined) { ), ); } - - if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) { - problems.push( - problem( - "error", - t("stats.ffmpegHighCpuUsage", { - camera: capitalizeFirstLetter(capitalizeAll(cameraName)), - ffmpegAvg, - }), - "/system#cameras", - ), - ); - } - - if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) { - problems.push( - problem( - "error", - t("stats.detectHighCpuUsage", { - camera: capitalizeFirstLetter(capitalizeAll(cameraName)), - detectAvg, - }), - "/system#cameras", - ), - ); - } }); // Add message if debug replay is active diff --git a/web/src/hooks/use-status-messages.ts b/web/src/hooks/use-status-messages.ts new file mode 100644 index 0000000000..375eaeca8d --- /dev/null +++ b/web/src/hooks/use-status-messages.ts @@ -0,0 +1,74 @@ +import { useEmbeddingsReindexProgress } from "@/api/ws"; +import { + StatusBarMessagesContext, + StatusMessage, +} from "@/context/statusbar-context"; +import useStats, { useAutoFrigateStats } from "@/hooks/use-stats"; +import { ProblemSeverity } from "@/types/stats"; +import { useContext, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +const SEVERITY_ORDER: Record = { + error: 0, + warning: 1, + info: 2, +}; + +/** + * Publishes the stats problems and reindex progress to the status bar, then + * returns every status bar message, most severe first. + */ +export default function useStatusMessages(): StatusMessage[] { + const { t } = useTranslation(["views/system"]); + const { messages, addMessage, clearMessages } = useContext( + StatusBarMessagesContext, + )!; + + const stats = useAutoFrigateStats(); + const { potentialProblems } = useStats(stats); + + useEffect(() => { + clearMessages("stats"); + potentialProblems.forEach((problem) => { + addMessage( + "stats", + problem.text, + problem.severity, + undefined, + problem.relevantLink, + ); + }); + }, [potentialProblems, addMessage, clearMessages]); + + const { payload: reindexState } = useEmbeddingsReindexProgress(); + + useEffect(() => { + if (reindexState) { + if (reindexState.status == "indexing") { + clearMessages("embeddings-reindex"); + addMessage( + "embeddings-reindex", + t("stats.reindexingEmbeddings", { + processed: Math.floor( + (reindexState.processed_objects / reindexState.total_objects) * + 100, + ), + }), + ); + } + if (reindexState.status === "completed") { + clearMessages("embeddings-reindex"); + } + } + }, [reindexState, addMessage, clearMessages, t]); + + return useMemo( + () => + Object.values(messages) + .flat() + .sort( + (a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity], + ), + [messages], + ); +} diff --git a/web/src/types/health.ts b/web/src/types/health.ts index 3f801acc15..4da3f014f4 100644 --- a/web/src/types/health.ts +++ b/web/src/types/health.ts @@ -27,16 +27,23 @@ export type HealthProblem = { externalLink?: string; /** render with a spinner instead of the severity icon (stream check running) */ pending?: boolean; - onDismiss?: () => void; + /** why a hidden row is hidden */ + hidden?: "acknowledged" | "muted"; + /** hide until the next occurrence; only for kinds that repeat */ + onAcknowledge?: () => void; + /** hide for good */ + onMute?: () => void; + /** show a hidden row again */ + onUnhide?: () => void; }; /** What the Health tab's filter shows. */ export type NoticeFilter = { - showDismissed: boolean; + showHidden: boolean; severities: HealthSeverity[]; }; export const DEFAULT_NOTICE_FILTER: NoticeFilter = { - showDismissed: false, + showHidden: false, severities: ["error", "warning", "info"], }; diff --git a/web/src/types/notice.ts b/web/src/types/notice.ts index 1b548626d4..11b3fad916 100644 --- a/web/src/types/notice.ts +++ b/web/src/types/notice.ts @@ -13,11 +13,16 @@ export type Notice = { first_seen: number; last_seen: number; count: number; - dismissed_at: number | null; + /** whether the kind repeats, so acknowledging it can hide it until next time */ + acknowledgeable: boolean; + /** hidden until the next occurrence */ + acknowledged_at: number | null; + /** hidden for good */ + muted_at: number | null; }; -/** a config or stream check row an admin dismissed */ -export type DismissedCheck = { +/** a config or stream check row an admin muted */ +export type MutedCheck = { id: string; - dismissed_at: number; + muted_at: number; }; diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts index 5438fb799f..7a08d1b03a 100644 --- a/web/src/types/stats.ts +++ b/web/src/types/stats.ts @@ -129,7 +129,6 @@ export type ProblemSeverity = "error" | "warning" | "info"; export type PotentialProblem = { text: string; severity: ProblemSeverity; - color: string; relevantLink?: string; };