Refactor Notices and System Health pane (#24243)

* refactor notices

* show startup message for enrichments in health pane

* tweaks
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 5cef6823a6
commit 7bc32fd4c9
50 changed files with 2471 additions and 898 deletions
+56
View File
@@ -8,6 +8,7 @@ import logging
import os
import re
import secrets
import threading
import time
from datetime import datetime
from pathlib import Path
@@ -34,6 +35,7 @@ from frigate.api.media_auth import (
from frigate.config import AuthConfig, ProxyConfig
from frigate.const import CONFIG_DIR, JWT_SECRET_ENV_VAR, PASSWORD_HASH_ALGORITHM
from frigate.models import User
from frigate.notices import raise_notice
logger = logging.getLogger(__name__)
@@ -253,6 +255,58 @@ class RateLimiter:
rateLimiter = RateLimiter()
# a failed login this long after the user's previous one opens a new burst
FAILED_LOGIN_BURST_GAP_S = 300
# the username comes from the request, so it is cut before it reaches a notice
MAX_NOTICE_USERNAME = 64
# unknown usernames are unbounded, so past this many open bursts a new one only
# reaches the log
MAX_OPEN_BURSTS = 100
class FailedLoginTracker:
"""Groups each user's failed logins into bursts, one notice per burst."""
def __init__(self) -> None:
self._lock = threading.Lock()
# user -> (burst start, last attempt), stalest attempt first
self._bursts: dict[str, tuple[int, float]] = {}
def record(self, user: str, now: float, *, known: bool = False) -> None:
"""Count a failed login toward the user's open burst, or open a new one.
Once MAX_OPEN_BURSTS are open, only a known user opens another.
"""
user = user[:MAX_NOTICE_USERNAME]
with self._lock:
# bursts that went quiet are over
while self._bursts:
stalest = next(iter(self._bursts))
if now - self._bursts[stalest][1] < FAILED_LOGIN_BURST_GAP_S:
break
del self._bursts[stalest]
if (
not known
and user not in self._bursts
and len(self._bursts) >= MAX_OPEN_BURSTS
):
return
start, _ = self._bursts.pop(user, (int(now), now))
self._bursts[user] = (start, now)
raise_notice("failed_login", scope=f"{user}:{start}", params={"user": user})
failed_logins = FailedLoginTracker()
def get_remote_addr(request: Request):
# fall back to the direct TCP peer when no proxy chain is present
@@ -880,6 +934,7 @@ def login(request: Request, body: AppPostLoginBody):
db_user: User = User.get_by_id(user)
except DoesNotExist:
logger.warning(f"Login failed for unknown user '{user}' from {remote_addr}")
failed_logins.record(user, time.time())
return JSONResponse(content={"message": "Login failed"}, status_code=401)
password_hash = db_user.password_hash
@@ -911,6 +966,7 @@ def login(request: Request, body: AppPostLoginBody):
logger.warning(
f"Login failed for user '{user}' (invalid password) from {remote_addr}"
)
failed_logins.record(user, time.time(), known=True)
return JSONResponse(content={"message": "Login failed"}, status_code=401)
+27 -17
View File
@@ -7,7 +7,6 @@ from fastapi.responses import JSONResponse
from frigate.api.auth import require_role
from frigate.api.defs.tags import Tags
from frigate.notices.registry import DismissResult
logger = logging.getLogger(__name__)
@@ -16,13 +15,13 @@ router = APIRouter(tags=[Tags.notices])
@router.get("/notices", dependencies=[Depends(require_role(["admin"]))])
def get_notices(request: Request, include_dismissed: bool = False) -> JSONResponse:
"""Get the active notices, most severe first.
"""Get notices, most severe first.
Args:
include_dismissed: Also return event notices the user has dismissed
include_dismissed: Also return dismissed notices, for the history view
Returns:
The active notices
The notices
"""
return JSONResponse(
content=request.app.notice_registry.active(include_dismissed=include_dismissed)
@@ -35,26 +34,37 @@ 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.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"}
)
# model notice ids contain a slash, so the id is a path parameter
@router.post(
"/notices/{notice_id}/dismiss", dependencies=[Depends(require_role(["admin"]))]
"/notices/{notice_id:path}/dismiss",
dependencies=[Depends(require_role(["admin"]))],
)
def dismiss_notice(request: Request, notice_id: str) -> JSONResponse:
"""Hide an event notice until it is raised again."""
result = request.app.notice_registry.dismiss(notice_id)
"""Hide a notice or a config or stream check row.
if result == DismissResult.not_found:
It stays hidden if the same problem happens again.
"""
if not request.app.notice_registry.dismiss(notice_id):
return JSONResponse(
content={"success": False, "message": "Notice not found"},
status_code=404,
)
if result == DismissResult.not_dismissable:
return JSONResponse(
content={
"success": False,
"message": "This notice clears itself when the problem is fixed",
},
status_code=400,
)
return JSONResponse(content={"success": True, "message": "Notice dismissed"})