Add a notice registry and System Health tab (#24178)

* add a notice registry and System Health tab

Problems Frigate detects on its own (ffmpeg crash loops, stuck detectors, failed model downloads, recordings deleted before their retention period) only ever existed as log lines. This adds a `NoticeRegistry` in the main process backed by two tables, an `update_notice` IPC topic so producers in other processes can reach it through the dispatcher, an admin-only API and websocket topic, and a Health tab that lists them. Kinds declare their own mode, severity, and category in one place: state notices are resolved by their producer, event notices are dismissed by the user.

* treat a prerelease as behind its final release

* fix notices clearing early

* rename menu items and update docs

* don't resolve the update notice on a failed version lookup
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent af60d2db48
commit f3a31e2fb4
55 changed files with 2346 additions and 117 deletions
+52 -2
View File
@@ -7,12 +7,22 @@ from pathlib import Path
import requests
from frigate.comms.inter_process import InterProcessRequestor
from frigate.const import UPDATE_MODEL_STATE
from frigate.const import UPDATE_MODEL_STATE, UPDATE_NOTICE
from frigate.types import ModelStatusTypesEnum
from frigate.util.file import FileLock
logger = logging.getLogger(__name__)
# target path -> first line of the last download error for it; every existing
# download function swallows its exceptions, so this is how the downloader
# thread learns why a file is still missing
last_download_error: dict[str, str] = {}
def _first_line(error: BaseException) -> str:
text = str(error).strip().splitlines()
return (text[0] if text else type(error).__name__)[:200]
class ModelDownloader:
def __init__(
@@ -48,6 +58,33 @@ class ModelDownloader:
)
self.download_thread.start()
def _notice_scope(self, file_name: str) -> str:
# per file: several loaders share one model_name with disjoint files,
# so a model wide scope lets one of them clear another's notice
return f"{self.model_name}/{file_name}"
def _report_failure(self, file_name: str, error: str) -> None:
self.requestor.send_data(
UPDATE_NOTICE,
{
"action": "raise",
"kind": "model_download_failed",
"scope": self._notice_scope(file_name),
"params": {"file": file_name, "error": error},
},
)
def _resolve_failure(self, file_name: str) -> None:
self.requestor.send_data(
UPDATE_NOTICE,
{
"action": "resolve",
"kind": "model_download_failed",
"scope": self._notice_scope(file_name),
"params": {},
},
)
def _download_models(self):
for file_name in self.file_names:
path = os.path.join(self.download_path, file_name)
@@ -57,8 +94,20 @@ class ModelDownloader:
if not os.path.exists(path):
with lock:
if not os.path.exists(path):
self.download_func(path)
try:
self.download_func(path)
except Exception as e:
self._report_failure(file_name, _first_line(e))
raise
if not os.path.exists(path):
self._report_failure(
file_name,
last_download_error.pop(path, "download failed"),
)
continue
self._resolve_failure(file_name)
self.requestor.send_data(
UPDATE_MODEL_STATE,
{
@@ -93,6 +142,7 @@ class ModelDownloader:
temporary_filename.rename(save_path)
except Exception as e:
logger.error(f"Error downloading model: {str(e)}")
last_download_error[save_path] = _first_line(e)
raise
if not silent: